mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(macos): load provider catalog during AI onboarding (#101132)
* feat(macos): load onboarding providers from gateway * test(crestodian): widen setup config mock * fix(crestodian): satisfy onboarding lint gate * chore(macos): refresh onboarding localization inventory * test(plugins): cover guided Copilot secret metadata
This commit is contained in:
parent
3accc99b43
commit
80537c1ba4
58 changed files with 1145 additions and 272 deletions
|
|
@ -18361,33 +18361,9 @@
|
|||
"surface": "apple",
|
||||
"id": "native.apple.dca2e9174fec56ce"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 56,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Claude (Anthropic)",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.99c49ed9c135cdcd"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 57,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "OpenAI",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.5c0a3a27717239d6"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 58,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Google Gemini",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.f85f71cced8225d6"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 371,
|
||||
"line": 377,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Looking for AI you already use…",
|
||||
"surface": "apple",
|
||||
|
|
@ -18395,7 +18371,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 373,
|
||||
"line": 379,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Checking for Claude Code, Codex, Gemini, and saved API keys.",
|
||||
"surface": "apple",
|
||||
|
|
@ -18403,7 +18379,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 403,
|
||||
"line": 409,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Couldn’t check this Mac for AI accounts",
|
||||
"surface": "apple",
|
||||
|
|
@ -18411,7 +18387,15 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 406,
|
||||
"line": 420,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Couldn’t load the full provider list",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.3b56fbfb9200f050"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 423,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Try again",
|
||||
"surface": "apple",
|
||||
|
|
@ -18419,7 +18403,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 414,
|
||||
"line": 431,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "None of the found options worked",
|
||||
"surface": "apple",
|
||||
|
|
@ -18427,23 +18411,15 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 415,
|
||||
"line": 432,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "The details are listed on each option above. You can fix the login and retry, or connect with an API key below.",
|
||||
"source": "The details are listed on each option above. You can fix the login and retry, or connect with an API key or token below.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.86f29266102f1bf3"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 417,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Check again",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.ff87d5ec9e15e581"
|
||||
"id": "native.apple.9947fd4c5e97875f"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 432,
|
||||
"line": 449,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Need help? Chat with Crestodian",
|
||||
"surface": "apple",
|
||||
|
|
@ -18451,7 +18427,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 445,
|
||||
"line": 462,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Your AI is ready",
|
||||
"surface": "apple",
|
||||
|
|
@ -18459,7 +18435,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 462,
|
||||
"line": 479,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "No AI accounts found on this Mac",
|
||||
"surface": "apple",
|
||||
|
|
@ -18467,39 +18443,63 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 464,
|
||||
"line": 481,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "That’s fine — you can connect one with an API key. If you use Claude Code, Codex, or the Gemini CLI on this Mac, sign in there first and hit “Check again”.",
|
||||
"source": "That’s fine — you can connect one with an API key or token. If you use Claude Code, Codex, or the Gemini CLI on this Mac, sign in there first and hit “Check again”.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.f87ba9e9eb97d8bb"
|
||||
"id": "native.apple.08d6423b6ed142a8"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 496,
|
||||
"line": 513,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Recommended",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.f51a11c7f56bdb29"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 585,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Connect with an API key instead…",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.361c4a91a6aef57b"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"kind": "ui-named-argument",
|
||||
"line": 596,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Connect with an API key",
|
||||
"source": "No key-based providers are available",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.99c1034543042eca"
|
||||
"id": "native.apple.d13850075df8f0f3"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 597,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Enable or install a text-inference provider plugin on this Gateway, then check again.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.0dd754dd6da55727"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 599,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Check again",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.ff87d5ec9e15e581"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 599,
|
||||
"line": 611,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Connect with an API key or token instead…",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.459cc3789bc7128d"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 622,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Connect with an API key or token",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.8347a2f698a63735"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 625,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Provider",
|
||||
"surface": "apple",
|
||||
|
|
@ -18507,23 +18507,23 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 619,
|
||||
"line": 633,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "API key or token",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.cb3d8a8506943d3a"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 645,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Connect",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.41d117fdc784999f"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 627,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Create a key at \\(self.model.manualProvider.consoleName), paste it here, and OpenClaw checks it with a real test question.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.3863947506f62f17"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 634,
|
||||
"line": 658,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "That key didn’t work",
|
||||
"surface": "apple",
|
||||
|
|
@ -18531,7 +18531,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 651,
|
||||
"line": 683,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Crestodian — setup helper",
|
||||
"surface": "apple",
|
||||
|
|
@ -18539,7 +18539,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 654,
|
||||
"line": 686,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Done",
|
||||
"surface": "apple",
|
||||
|
|
@ -18547,7 +18547,7 @@
|
|||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 695,
|
||||
"line": 727,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
|
||||
"source": "Open help…",
|
||||
"surface": "apple",
|
||||
|
|
|
|||
|
|
@ -42,38 +42,10 @@ final class OnboardingAISetupModel {
|
|||
case connected
|
||||
}
|
||||
|
||||
enum ManualProvider: String, CaseIterable, Identifiable {
|
||||
case anthropic
|
||||
case openai
|
||||
case google
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .anthropic: "Claude (Anthropic)"
|
||||
case .openai: "OpenAI"
|
||||
case .google: "Google Gemini"
|
||||
}
|
||||
}
|
||||
|
||||
var keyHint: String {
|
||||
switch self {
|
||||
case .anthropic: "sk-ant-…"
|
||||
case .openai: "sk-…"
|
||||
case .google: "AIza…"
|
||||
}
|
||||
}
|
||||
|
||||
var consoleName: String {
|
||||
switch self {
|
||||
case .anthropic: "console.anthropic.com"
|
||||
case .openai: "platform.openai.com"
|
||||
case .google: "aistudio.google.com"
|
||||
}
|
||||
}
|
||||
struct ManualProvider: Identifiable, Equatable, Decodable {
|
||||
let id: String
|
||||
let label: String
|
||||
let hint: String?
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .idle {
|
||||
|
|
@ -86,6 +58,9 @@ final class OnboardingAISetupModel {
|
|||
}
|
||||
|
||||
private(set) var candidates: [Candidate] = []
|
||||
private(set) var manualProviders: [ManualProvider] = []
|
||||
private(set) var providerCatalogLoaded = false
|
||||
private(set) var providerCatalogError: String?
|
||||
private(set) var statuses: [String: CandidateStatus] = [:]
|
||||
private(set) var selectedKind: String?
|
||||
private(set) var connectedModelRef: String?
|
||||
|
|
@ -94,12 +69,16 @@ final class OnboardingAISetupModel {
|
|||
/// Set once every detected candidate failed; opens the manual key form.
|
||||
private(set) var exhaustedAutoCandidates = false
|
||||
|
||||
var manualProvider: ManualProvider = .anthropic
|
||||
var manualProviderID = ""
|
||||
var manualKey: String = ""
|
||||
private(set) var manualTesting = false
|
||||
private(set) var manualError: String?
|
||||
var showManualEntry = false
|
||||
|
||||
var selectedManualProvider: ManualProvider? {
|
||||
self.manualProviders.first { $0.id == self.manualProviderID }
|
||||
}
|
||||
|
||||
var connected: Bool {
|
||||
self.phase == .connected
|
||||
}
|
||||
|
|
@ -125,6 +104,7 @@ final class OnboardingAISetupModel {
|
|||
}
|
||||
|
||||
let candidates: [DetectedCandidate]
|
||||
let manualProviders: [ManualProvider]?
|
||||
let workspace: String
|
||||
let configuredModel: String?
|
||||
let setupComplete: Bool
|
||||
|
|
@ -148,6 +128,9 @@ final class OnboardingAISetupModel {
|
|||
self.attemptToken = UUID()
|
||||
self.phase = .idle
|
||||
self.candidates = []
|
||||
self.manualProviders = []
|
||||
self.providerCatalogLoaded = false
|
||||
self.providerCatalogError = nil
|
||||
self.statuses = [:]
|
||||
self.selectedKind = nil
|
||||
self.detectError = nil
|
||||
|
|
@ -162,6 +145,7 @@ final class OnboardingAISetupModel {
|
|||
let token = self.attemptToken
|
||||
self.phase = .detecting
|
||||
self.detectError = nil
|
||||
self.providerCatalogError = nil
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(
|
||||
method: "crestodian.setup.detect",
|
||||
|
|
@ -170,6 +154,7 @@ final class OnboardingAISetupModel {
|
|||
retryTransportFailures: true)
|
||||
guard token == self.attemptToken else { return }
|
||||
let result = try JSONDecoder().decode(DetectResult.self, from: data)
|
||||
let manualProviders = result.manualProviders ?? []
|
||||
self.candidates = result.candidates.map { detected in
|
||||
Candidate(
|
||||
kind: detected.kind,
|
||||
|
|
@ -179,6 +164,14 @@ final class OnboardingAISetupModel {
|
|||
recommended: detected.recommended,
|
||||
credentials: detected.credentials)
|
||||
}
|
||||
self.manualProviders = manualProviders
|
||||
self.providerCatalogLoaded = result.manualProviders != nil
|
||||
if result.manualProviders == nil {
|
||||
self.providerCatalogError = OnboardingAISetupError.providerCatalogUnavailable.localizedDescription
|
||||
}
|
||||
if !manualProviders.contains(where: { $0.id == self.manualProviderID }) {
|
||||
self.manualProviderID = manualProviders.first?.id ?? ""
|
||||
}
|
||||
for candidate in self.candidates {
|
||||
self.statuses[candidate.kind] = .untried
|
||||
}
|
||||
|
|
@ -188,7 +181,7 @@ final class OnboardingAISetupModel {
|
|||
// stays one click away while the test runs server-side.
|
||||
await self.activate(kind: first.kind)
|
||||
} else {
|
||||
self.showManualEntry = true
|
||||
self.showManualEntry = !self.manualProviders.isEmpty
|
||||
}
|
||||
} catch {
|
||||
guard token == self.attemptToken else { return }
|
||||
|
|
@ -258,7 +251,7 @@ final class OnboardingAISetupModel {
|
|||
|
||||
func submitManualKey() {
|
||||
let key = self.manualKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty, !self.manualTesting else { return }
|
||||
guard let provider = self.selectedManualProvider, !key.isEmpty, !self.manualTesting else { return }
|
||||
self.manualError = nil
|
||||
self.manualTesting = true
|
||||
let token = self.attemptToken
|
||||
|
|
@ -269,7 +262,7 @@ final class OnboardingAISetupModel {
|
|||
method: "crestodian.setup.activate",
|
||||
params: [
|
||||
"kind": AnyCodable("api-key"),
|
||||
"provider": AnyCodable(self.manualProvider.rawValue),
|
||||
"authChoice": AnyCodable(provider.id),
|
||||
"apiKey": AnyCodable(key),
|
||||
],
|
||||
timeoutMs: 150_000,
|
||||
|
|
@ -281,7 +274,7 @@ final class OnboardingAISetupModel {
|
|||
self.finishConnected(kind: "api-key", result: result)
|
||||
} else {
|
||||
self.manualError = Self.friendlyFailure(
|
||||
label: self.manualProvider.title,
|
||||
label: provider.label,
|
||||
status: result.status,
|
||||
error: result.error)
|
||||
}
|
||||
|
|
@ -333,7 +326,8 @@ final class OnboardingAISetupModel {
|
|||
|
||||
var connectedSummary: String {
|
||||
guard let modelRef = self.connectedModelRef else { return "Your AI is connected." }
|
||||
let label = self.candidates.first { $0.kind == self.selectedKind }?.label
|
||||
let label = self.candidates.first { $0.kind == self.selectedKind }?.label ??
|
||||
(self.selectedKind == "api-key" ? self.selectedManualProvider?.label : nil)
|
||||
let via = label.map { " via \($0)" } ?? ""
|
||||
if let latency = self.connectedLatencyMs {
|
||||
let seconds = Double(latency) / 1000
|
||||
|
|
@ -343,6 +337,18 @@ final class OnboardingAISetupModel {
|
|||
}
|
||||
}
|
||||
|
||||
private enum OnboardingAISetupError: LocalizedError {
|
||||
case providerCatalogUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .providerCatalogUnavailable:
|
||||
"The Gateway is running an older OpenClaw version that doesn’t provide the " +
|
||||
"supported provider list. Update OpenClaw on the gateway, then try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingAISetupView: View {
|
||||
@Bindable var model: OnboardingAISetupModel
|
||||
@State private var showCrestodianChat = false
|
||||
|
|
@ -409,10 +415,21 @@ struct OnboardingAISetupView: View {
|
|||
}
|
||||
}
|
||||
|
||||
if let providerCatalogError = self.model.providerCatalogError {
|
||||
OnboardingErrorCard(
|
||||
title: "Couldn’t load the full provider list",
|
||||
message: providerCatalogError,
|
||||
docsSlug: "start/onboarding",
|
||||
retryTitle: "Try again")
|
||||
{
|
||||
self.model.retryFromScratch()
|
||||
}
|
||||
}
|
||||
|
||||
if self.model.exhaustedAutoCandidates, !self.model.connected {
|
||||
OnboardingErrorCard(
|
||||
title: "None of the found options worked",
|
||||
message: "The details are listed on each option above. You can fix the login and retry, or connect with an API key below.",
|
||||
message: "The details are listed on each option above. You can fix the login and retry, or connect with an API key or token below.",
|
||||
docsSlug: "concepts/model-providers",
|
||||
retryTitle: "Check again")
|
||||
{
|
||||
|
|
@ -420,7 +437,7 @@ struct OnboardingAISetupView: View {
|
|||
}
|
||||
}
|
||||
|
||||
if !self.model.connected {
|
||||
if !self.model.connected, self.model.providerCatalogLoaded {
|
||||
self.manualSection
|
||||
}
|
||||
|
||||
|
|
@ -462,7 +479,7 @@ struct OnboardingAISetupView: View {
|
|||
Text("No AI accounts found on this Mac")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"That’s fine — you can connect one with an API key. " +
|
||||
"That’s fine — you can connect one with an API key or token. " +
|
||||
"If you use Claude Code, Codex, or the Gemini CLI on this Mac, " +
|
||||
"sign in there first and hit “Check again”.")
|
||||
.font(.subheadline)
|
||||
|
|
@ -574,7 +591,16 @@ struct OnboardingAISetupView: View {
|
|||
|
||||
private var manualSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.model.candidates.isEmpty || self.model.showManualEntry {
|
||||
if self.model.manualProviders.isEmpty {
|
||||
OnboardingErrorCard(
|
||||
title: "No key-based providers are available",
|
||||
message: "Enable or install a text-inference provider plugin on this Gateway, then check again.",
|
||||
docsSlug: "concepts/model-providers",
|
||||
retryTitle: "Check again")
|
||||
{
|
||||
self.model.retryFromScratch()
|
||||
}
|
||||
} else if self.model.candidates.isEmpty || self.model.showManualEntry {
|
||||
self.manualForm
|
||||
} else {
|
||||
Button {
|
||||
|
|
@ -582,7 +608,7 @@ struct OnboardingAISetupView: View {
|
|||
self.model.showManualEntry = true
|
||||
}
|
||||
} label: {
|
||||
Label("Connect with an API key instead…", systemImage: "key")
|
||||
Label("Connect with an API key or token instead…", systemImage: "key")
|
||||
.font(.callout)
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
|
|
@ -593,18 +619,18 @@ struct OnboardingAISetupView: View {
|
|||
|
||||
private var manualForm: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Connect with an API key")
|
||||
Text("Connect with an API key or token")
|
||||
.font(.headline)
|
||||
HStack(spacing: 8) {
|
||||
Picker("Provider", selection: self.$model.manualProvider) {
|
||||
ForEach(OnboardingAISetupModel.ManualProvider.allCases) { provider in
|
||||
Text(provider.title).tag(provider)
|
||||
Picker("Provider", selection: self.$model.manualProviderID) {
|
||||
ForEach(self.model.manualProviders) { provider in
|
||||
Text(provider.label).tag(provider.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 170)
|
||||
.frame(width: 230)
|
||||
|
||||
SecureField(self.model.manualProvider.keyHint, text: self.$model.manualKey)
|
||||
SecureField("API key or token", text: self.$model.manualKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { self.model.submitManualKey() }
|
||||
|
||||
|
|
@ -624,9 +650,7 @@ struct OnboardingAISetupView: View {
|
|||
.disabled(self.model.manualTesting ||
|
||||
self.model.manualKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text(
|
||||
"Create a key at \(self.model.manualProvider.consoleName), paste it here, " +
|
||||
"and OpenClaw checks it with a real test question.")
|
||||
Text(self.manualProviderHelp)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let manualError = self.model.manualError {
|
||||
|
|
@ -645,6 +669,14 @@ struct OnboardingAISetupView: View {
|
|||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
|
||||
private var manualProviderHelp: String {
|
||||
let hint = self.model.selectedManualProvider?.hint?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let hint, !hint.isEmpty else {
|
||||
return "Paste the key or token here, and OpenClaw checks it with a real test question."
|
||||
}
|
||||
return "\(hint). Paste it here, and OpenClaw checks it with a real test question."
|
||||
}
|
||||
|
||||
private var crestodianSheet: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
|
|
|
|||
|
|
@ -3665,17 +3665,20 @@ public struct CrestodianSetupDetectParams: Codable, Sendable {}
|
|||
|
||||
public struct CrestodianSetupDetectResult: Codable, Sendable {
|
||||
public let candidates: [[String: AnyCodable]]
|
||||
public let manualproviders: [[String: AnyCodable]]
|
||||
public let workspace: String
|
||||
public let configuredmodel: String?
|
||||
public let setupcomplete: Bool
|
||||
|
||||
public init(
|
||||
candidates: [[String: AnyCodable]],
|
||||
manualproviders: [[String: AnyCodable]],
|
||||
workspace: String,
|
||||
configuredmodel: String?,
|
||||
setupcomplete: Bool)
|
||||
{
|
||||
self.candidates = candidates
|
||||
self.manualproviders = manualproviders
|
||||
self.workspace = workspace
|
||||
self.configuredmodel = configuredmodel
|
||||
self.setupcomplete = setupcomplete
|
||||
|
|
@ -3683,6 +3686,7 @@ public struct CrestodianSetupDetectResult: Codable, Sendable {
|
|||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case candidates
|
||||
case manualproviders = "manualProviders"
|
||||
case workspace
|
||||
case configuredmodel = "configuredModel"
|
||||
case setupcomplete = "setupComplete"
|
||||
|
|
@ -3691,25 +3695,25 @@ public struct CrestodianSetupDetectResult: Codable, Sendable {
|
|||
|
||||
public struct CrestodianSetupActivateParams: Codable, Sendable {
|
||||
public let kind: AnyCodable
|
||||
public let provider: String?
|
||||
public let authchoice: String?
|
||||
public let apikey: String?
|
||||
public let workspace: String?
|
||||
|
||||
public init(
|
||||
kind: AnyCodable,
|
||||
provider: String?,
|
||||
authchoice: String?,
|
||||
apikey: String?,
|
||||
workspace: String?)
|
||||
{
|
||||
self.kind = kind
|
||||
self.provider = provider
|
||||
self.authchoice = authchoice
|
||||
self.apikey = apikey
|
||||
self.workspace = workspace
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case kind
|
||||
case provider
|
||||
case authchoice = "authChoice"
|
||||
case apikey = "apiKey"
|
||||
case workspace
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Interactive Crestodian opens the same TUI shell as `openclaw tui`, with a Cresto
|
|||
|
||||
It does not dump secrets or load plugin CLI commands just to start.
|
||||
|
||||
Use `status` for the detailed inventory: config path, docs/source paths, local CLI probes, API-key presence, agents, model, and Gateway details.
|
||||
Use `status` for the detailed inventory: config path, docs/source paths, local CLI probes, key/token presence, agents, model, and Gateway details.
|
||||
|
||||
Crestodian uses the same reference discovery as regular agents: in a Git checkout it points at local `docs/` and the source tree; in an npm install it uses bundled docs and links to [https://github.com/openclaw/openclaw](https://github.com/openclaw/openclaw), with guidance to check source when docs are not enough.
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ When no model is configured, setup picks the first usable backend in this order
|
|||
|
||||
If none are available, setup still writes the default workspace and leaves the model unset. Install or log into Codex/Claude Code/Gemini CLI, or expose `OPENAI_API_KEY`/`ANTHROPIC_API_KEY`, then run setup again.
|
||||
|
||||
The macOS app drives the same ladder through the `crestodian.setup.detect` and `crestodian.setup.activate` gateway methods: detect lists every reusable backend it finds, activate live-tests one candidate (a real "reply with OK" completion) and only persists the model, workspace, and gateway defaults after the test passes. A failing candidate never changes config; the app automatically walks down the ladder and finally offers a manual API-key step (Anthropic, OpenAI, or Google) that is verified the same way before it is saved.
|
||||
The macOS app drives the same ladder through the `crestodian.setup.detect` and `crestodian.setup.activate` gateway methods: detect lists every reusable backend it finds, activate live-tests one candidate (a real "reply with OK" completion) and only persists the model, workspace, and gateway defaults after the test passes. A failing candidate never changes config; the app automatically walks down the ladder and finally offers a manual key/token step populated from the Gateway's active text-inference provider plugins. The selected provider owns its starter model and config, and the credential is verified the same way before it is saved.
|
||||
|
||||
## AI conversation
|
||||
|
||||
|
|
|
|||
|
|
@ -73,12 +73,13 @@ Where does the **Gateway** run?
|
|||
the next option and shows why the previous one failed. If several options
|
||||
are found you can switch between them before continuing.
|
||||
|
||||
If nothing is found (or nothing works), a manual step accepts an API key for
|
||||
Anthropic, OpenAI, or Google, verifies it the same way, and stores it as an
|
||||
auth profile. Next remains locked until one backend has passed its live test,
|
||||
so the first agent chat can never start without working inference. The
|
||||
Crestodian chat stays available from this page (and later under
|
||||
Settings → Crestodian) for help in plain language.
|
||||
If nothing is found (or nothing works), the manual key/token picker loads the
|
||||
Gateway's active text-inference provider plugins instead of using a fixed app
|
||||
list. The selected provider supplies its starter model and config; OpenClaw
|
||||
verifies the credential with the same live test before storing its auth profile. Next
|
||||
remains locked until one backend has passed, so the first agent chat cannot
|
||||
start without working inference. The Crestodian chat stays available from this
|
||||
page (and later under Settings → Crestodian) for help in plain language.
|
||||
|
||||
Configure Later skips this step.
|
||||
</Step>
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@
|
|||
"provider": "anthropic",
|
||||
"method": "setup-token",
|
||||
"choiceId": "setup-token",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Anthropic setup-token",
|
||||
"choiceHint": "Manual token path",
|
||||
"assistantPriority": 40,
|
||||
|
|
@ -234,6 +235,7 @@
|
|||
"provider": "anthropic",
|
||||
"method": "api-key",
|
||||
"choiceId": "apiKey",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Anthropic API key",
|
||||
"groupId": "anthropic",
|
||||
"groupLabel": "Anthropic",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"provider": "arcee",
|
||||
"method": "arcee-platform",
|
||||
"choiceId": "arceeai-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Arcee AI API key",
|
||||
"choiceHint": "Direct (chat.arcee.ai)",
|
||||
"groupId": "arcee",
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"provider": "arcee",
|
||||
"method": "openrouter",
|
||||
"choiceId": "arceeai-openrouter",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "OpenRouter API key",
|
||||
"choiceHint": "Via OpenRouter (openrouter.ai)",
|
||||
"groupId": "arcee",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@
|
|||
"provider": "byteplus",
|
||||
"method": "api-key",
|
||||
"choiceId": "byteplus-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "BytePlus API key",
|
||||
"groupId": "byteplus",
|
||||
"groupLabel": "BytePlus",
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
"provider": "cerebras",
|
||||
"method": "api-key",
|
||||
"choiceId": "cerebras-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Cerebras API key",
|
||||
"groupId": "cerebras",
|
||||
"groupLabel": "Cerebras",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"provider": "chutes",
|
||||
"method": "api-key",
|
||||
"choiceId": "chutes-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Chutes API key",
|
||||
"choiceHint": "Open-source models including Llama, DeepSeek, and more",
|
||||
"groupId": "chutes",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
"provider": "cohere",
|
||||
"method": "api-key",
|
||||
"choiceId": "cohere-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Cohere API key",
|
||||
"groupId": "cohere",
|
||||
"groupLabel": "Cohere",
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@
|
|||
"provider": "deepinfra",
|
||||
"method": "api-key",
|
||||
"choiceId": "deepinfra-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "DeepInfra API key",
|
||||
"choiceHint": "Unified API for open source models",
|
||||
"groupId": "deepinfra",
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"provider": "deepseek",
|
||||
"method": "api-key",
|
||||
"choiceId": "deepseek-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "DeepSeek API key",
|
||||
"groupId": "deepseek",
|
||||
"groupLabel": "DeepSeek",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"provider": "fireworks",
|
||||
"method": "api-key",
|
||||
"choiceId": "fireworks-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Fireworks API key",
|
||||
"groupId": "fireworks",
|
||||
"groupLabel": "Fireworks",
|
||||
|
|
|
|||
|
|
@ -431,6 +431,7 @@ export default definePluginEntry({
|
|||
label: "GitHub device login",
|
||||
hint: "Browser device-code flow",
|
||||
kind: "device_code",
|
||||
starterModel: DEFAULT_COPILOT_MODEL,
|
||||
run: async (ctx) => await runGitHubCopilotAuth(ctx),
|
||||
runNonInteractive: async (ctx) => await runGitHubCopilotNonInteractiveAuth(ctx),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"provider": "github-copilot",
|
||||
"method": "device",
|
||||
"choiceId": "github-copilot",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "GitHub Copilot",
|
||||
"choiceHint": "Device login with your GitHub account",
|
||||
"groupId": "copilot",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"provider": "gmi",
|
||||
"method": "api-key",
|
||||
"choiceId": "gmi-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "GMI Cloud API key",
|
||||
"choiceHint": "OpenAI-compatible GMI Cloud endpoint",
|
||||
"groupId": "gmi",
|
||||
|
|
|
|||
|
|
@ -628,6 +628,7 @@
|
|||
"provider": "google",
|
||||
"method": "api-key",
|
||||
"choiceId": "gemini-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Google Gemini API key",
|
||||
"groupId": "google",
|
||||
"groupLabel": "Google",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "groq",
|
||||
"method": "api-key",
|
||||
"choiceId": "groq-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Groq API key",
|
||||
"choiceHint": "Fast OpenAI-compatible inference",
|
||||
"groupId": "groq",
|
||||
"groupLabel": "Groq",
|
||||
"optionKey": "groqApiKey",
|
||||
"cliFlag": "--groq-api-key",
|
||||
"cliOption": "--groq-api-key <key>",
|
||||
"cliDescription": "Groq API key"
|
||||
}
|
||||
],
|
||||
"modelCatalog": {
|
||||
"providers": {
|
||||
"groq": {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"provider": "huggingface",
|
||||
"method": "api-key",
|
||||
"choiceId": "huggingface-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Hugging Face API key",
|
||||
"choiceHint": "Inference API (HF token)",
|
||||
"groupId": "huggingface",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"provider": "kilocode",
|
||||
"method": "api-key",
|
||||
"choiceId": "kilocode-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Kilo Gateway API key",
|
||||
"choiceHint": "API key (OpenRouter-compatible)",
|
||||
"groupId": "kilocode",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"provider": "kimi",
|
||||
"method": "api-key",
|
||||
"choiceId": "kimi-code-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Kimi Code API key (subscription)",
|
||||
"groupId": "moonshot",
|
||||
"groupLabel": "Moonshot AI (Kimi K2.6)",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"provider": "litellm",
|
||||
"method": "api-key",
|
||||
"choiceId": "litellm-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "LiteLLM API key",
|
||||
"choiceHint": "Unified gateway for 100+ LLM providers",
|
||||
"groupId": "litellm",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"provider": "lmstudio",
|
||||
"method": "custom",
|
||||
"choiceId": "lmstudio",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "LM Studio",
|
||||
"choiceHint": "Local/self-hosted LM Studio server",
|
||||
"optionKey": "lmstudioApiKey",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"provider": "minimax",
|
||||
"method": "api-global",
|
||||
"choiceId": "minimax-global-api",
|
||||
"appGuidedSecret": true,
|
||||
"deprecatedChoiceIds": ["minimax", "minimax-api", "minimax-cloud", "minimax-api-lightning"],
|
||||
"choiceLabel": "MiniMax API key (Global)",
|
||||
"choiceHint": "Global endpoint - api.minimax.io",
|
||||
|
|
@ -66,6 +67,7 @@
|
|||
"provider": "minimax",
|
||||
"method": "api-cn",
|
||||
"choiceId": "minimax-cn-api",
|
||||
"appGuidedSecret": true,
|
||||
"deprecatedChoiceIds": ["minimax-api-key-cn"],
|
||||
"choiceLabel": "MiniMax API key (CN)",
|
||||
"choiceHint": "CN endpoint - api.minimaxi.com",
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@
|
|||
"provider": "mistral",
|
||||
"method": "api-key",
|
||||
"choiceId": "mistral-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Mistral API key",
|
||||
"groupId": "mistral",
|
||||
"groupLabel": "Mistral AI",
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@
|
|||
"provider": "moonshot",
|
||||
"method": "api-key",
|
||||
"choiceId": "moonshot-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Moonshot API key (.ai)",
|
||||
"groupId": "moonshot",
|
||||
"groupLabel": "Moonshot AI (Kimi K2.6)",
|
||||
|
|
@ -164,6 +165,7 @@
|
|||
"provider": "moonshot",
|
||||
"method": "api-key-cn",
|
||||
"choiceId": "moonshot-api-key-cn",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Moonshot API key (.cn)",
|
||||
"groupId": "moonshot",
|
||||
"groupLabel": "Moonshot AI (Kimi K2.6)",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"provider": "novita",
|
||||
"method": "api-key",
|
||||
"choiceId": "novita-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "NovitaAI API key",
|
||||
"choiceHint": "OpenAI-compatible NovitaAI endpoint",
|
||||
"groupId": "novita",
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@
|
|||
"provider": "nvidia",
|
||||
"method": "api-key",
|
||||
"choiceId": "nvidia-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "NVIDIA API key",
|
||||
"groupId": "nvidia",
|
||||
"groupLabel": "NVIDIA",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
"provider": "ollama-cloud",
|
||||
"method": "api-key",
|
||||
"choiceId": "ollama-cloud",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Ollama Cloud",
|
||||
"choiceHint": "Hosted models via ollama.com",
|
||||
"groupId": "ollama",
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@
|
|||
"provider": "openai",
|
||||
"method": "api-key",
|
||||
"choiceId": "openai-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "OpenAI API Key",
|
||||
"choiceHint": "Use your OpenAI API key directly",
|
||||
"assistantPriority": 5,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@
|
|||
"provider": "opencode-go",
|
||||
"method": "api-key",
|
||||
"choiceId": "opencode-go",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "OpenCode Go catalog",
|
||||
"groupId": "opencode",
|
||||
"groupLabel": "OpenCode",
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@
|
|||
"provider": "opencode",
|
||||
"method": "api-key",
|
||||
"choiceId": "opencode-zen",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "OpenCode Zen catalog",
|
||||
"groupId": "opencode",
|
||||
"groupLabel": "OpenCode",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
"provider": "openrouter",
|
||||
"method": "api-key",
|
||||
"choiceId": "openrouter-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "OpenRouter API key",
|
||||
"groupId": "openrouter",
|
||||
"groupLabel": "OpenRouter",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@
|
|||
"provider": "qianfan",
|
||||
"method": "api-key",
|
||||
"choiceId": "qianfan-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Qianfan API key",
|
||||
"groupId": "qianfan",
|
||||
"groupLabel": "Qianfan",
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@
|
|||
"provider": "qwen",
|
||||
"method": "standard-api-key-cn",
|
||||
"choiceId": "qwen-standard-api-key-cn",
|
||||
"appGuidedSecret": true,
|
||||
"deprecatedChoiceIds": ["modelstudio-standard-api-key-cn"],
|
||||
"choiceLabel": "Standard API Key for China (pay-as-you-go)",
|
||||
"choiceHint": "Endpoint: dashscope.aliyuncs.com",
|
||||
|
|
@ -262,6 +263,7 @@
|
|||
"provider": "qwen",
|
||||
"method": "standard-api-key",
|
||||
"choiceId": "qwen-standard-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"deprecatedChoiceIds": ["modelstudio-standard-api-key"],
|
||||
"choiceLabel": "Standard API Key for Global/Intl (pay-as-you-go)",
|
||||
"choiceHint": "Endpoint: dashscope-intl.aliyuncs.com",
|
||||
|
|
@ -277,6 +279,7 @@
|
|||
"provider": "qwen",
|
||||
"method": "api-key-cn",
|
||||
"choiceId": "qwen-api-key-cn",
|
||||
"appGuidedSecret": true,
|
||||
"deprecatedChoiceIds": ["modelstudio-api-key-cn"],
|
||||
"choiceLabel": "Coding Plan API Key for China (subscription)",
|
||||
"choiceHint": "Endpoint: coding.dashscope.aliyuncs.com",
|
||||
|
|
@ -292,6 +295,7 @@
|
|||
"provider": "qwen",
|
||||
"method": "api-key",
|
||||
"choiceId": "qwen-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"deprecatedChoiceIds": ["modelstudio-api-key"],
|
||||
"choiceLabel": "Coding Plan API Key for Global/Intl (subscription)",
|
||||
"choiceHint": "Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
|
|
@ -307,6 +311,7 @@
|
|||
"provider": "qwen-oauth",
|
||||
"method": "api-key",
|
||||
"choiceId": "qwen-oauth",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Qwen OAuth",
|
||||
"choiceHint": "Portal token for portal.qwen.ai",
|
||||
"groupId": "qwen",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@
|
|||
"provider": "stepfun",
|
||||
"method": "standard-api-key-cn",
|
||||
"choiceId": "stepfun-standard-api-key-cn",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "StepFun Standard API key (China)",
|
||||
"choiceHint": "Endpoint: api.stepfun.com/v1",
|
||||
"groupId": "stepfun",
|
||||
|
|
@ -100,6 +101,7 @@
|
|||
"provider": "stepfun",
|
||||
"method": "standard-api-key-intl",
|
||||
"choiceId": "stepfun-standard-api-key-intl",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "StepFun Standard API key (Global/Intl)",
|
||||
"choiceHint": "Endpoint: api.stepfun.ai/v1",
|
||||
"groupId": "stepfun",
|
||||
|
|
@ -114,6 +116,7 @@
|
|||
"provider": "stepfun-plan",
|
||||
"method": "plan-api-key-cn",
|
||||
"choiceId": "stepfun-plan-api-key-cn",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "StepFun Step Plan API key (China)",
|
||||
"choiceHint": "Endpoint: api.stepfun.com/step_plan/v1",
|
||||
"groupId": "stepfun",
|
||||
|
|
@ -128,6 +131,7 @@
|
|||
"provider": "stepfun-plan",
|
||||
"method": "plan-api-key-intl",
|
||||
"choiceId": "stepfun-plan-api-key-intl",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "StepFun Step Plan API key (Global/Intl)",
|
||||
"choiceHint": "Endpoint: api.stepfun.ai/step_plan/v1",
|
||||
"groupId": "stepfun",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"provider": "synthetic",
|
||||
"method": "api-key",
|
||||
"choiceId": "synthetic-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Synthetic API key",
|
||||
"groupId": "synthetic",
|
||||
"groupLabel": "Synthetic",
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@
|
|||
"provider": "tencent-tokenhub",
|
||||
"method": "api-key",
|
||||
"choiceId": "tokenhub-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Tencent TokenHub",
|
||||
"groupId": "tencent",
|
||||
"groupLabel": "Tencent Cloud",
|
||||
|
|
@ -131,6 +132,7 @@
|
|||
"provider": "tencent-tokenplan",
|
||||
"method": "api-key",
|
||||
"choiceId": "tokenplan-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Tencent TokenPlan",
|
||||
"groupId": "tencent",
|
||||
"groupLabel": "Tencent Cloud",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"provider": "together",
|
||||
"method": "api-key",
|
||||
"choiceId": "together-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Together AI API key",
|
||||
"groupId": "together",
|
||||
"groupLabel": "Together AI",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"provider": "venice",
|
||||
"method": "api-key",
|
||||
"choiceId": "venice-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Venice AI API key",
|
||||
"groupId": "venice",
|
||||
"groupLabel": "Venice AI",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"provider": "vercel-ai-gateway",
|
||||
"method": "api-key",
|
||||
"choiceId": "ai-gateway-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Vercel AI Gateway API key",
|
||||
"groupId": "ai-gateway",
|
||||
"groupLabel": "Vercel AI Gateway",
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@
|
|||
"provider": "volcengine",
|
||||
"method": "api-key",
|
||||
"choiceId": "volcengine-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Volcano Engine API key",
|
||||
"groupId": "volcengine",
|
||||
"groupLabel": "Volcano Engine",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@
|
|||
"provider": "xai",
|
||||
"method": "api-key",
|
||||
"choiceId": "xai-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "xAI API key",
|
||||
"groupId": "xai",
|
||||
"groupLabel": "xAI (Grok)",
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@
|
|||
"provider": "xiaomi",
|
||||
"method": "api-key",
|
||||
"choiceId": "xiaomi-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Xiaomi API key (Pay-as-you-go)",
|
||||
"groupId": "xiaomi",
|
||||
"groupLabel": "Xiaomi",
|
||||
|
|
@ -153,6 +154,7 @@
|
|||
"provider": "xiaomi-token-plan",
|
||||
"method": "token-plan-ams",
|
||||
"choiceId": "xiaomi-token-plan-ams",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Xiaomi Token Plan (Europe)",
|
||||
"choiceHint": "Endpoint preset: token-plan-ams.xiaomimimo.com/v1",
|
||||
"groupId": "xiaomi",
|
||||
|
|
@ -167,6 +169,7 @@
|
|||
"provider": "xiaomi-token-plan",
|
||||
"method": "token-plan-cn",
|
||||
"choiceId": "xiaomi-token-plan-cn",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Xiaomi Token Plan (China)",
|
||||
"choiceHint": "Endpoint preset: token-plan-cn.xiaomimimo.com/v1",
|
||||
"groupId": "xiaomi",
|
||||
|
|
@ -181,6 +184,7 @@
|
|||
"provider": "xiaomi-token-plan",
|
||||
"method": "token-plan-sgp",
|
||||
"choiceId": "xiaomi-token-plan-sgp",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Xiaomi Token Plan (Singapore)",
|
||||
"choiceHint": "Endpoint preset: token-plan-sgp.xiaomimimo.com/v1",
|
||||
"groupId": "xiaomi",
|
||||
|
|
|
|||
|
|
@ -261,6 +261,7 @@
|
|||
"provider": "zai",
|
||||
"method": "api-key",
|
||||
"choiceId": "zai-api-key",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Z.AI API key",
|
||||
"groupId": "zai",
|
||||
"groupLabel": "Z.AI",
|
||||
|
|
@ -274,6 +275,7 @@
|
|||
"provider": "zai",
|
||||
"method": "coding-global",
|
||||
"choiceId": "zai-coding-global",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Coding-Plan-Global",
|
||||
"choiceHint": "GLM Coding Plan Global (api.z.ai)",
|
||||
"groupId": "zai",
|
||||
|
|
@ -288,6 +290,7 @@
|
|||
"provider": "zai",
|
||||
"method": "coding-cn",
|
||||
"choiceId": "zai-coding-cn",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Coding-Plan-CN",
|
||||
"choiceHint": "GLM Coding Plan CN (open.bigmodel.cn)",
|
||||
"groupId": "zai",
|
||||
|
|
@ -302,6 +305,7 @@
|
|||
"provider": "zai",
|
||||
"method": "global",
|
||||
"choiceId": "zai-global",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "Global",
|
||||
"choiceHint": "Z.AI Global (api.z.ai)",
|
||||
"groupId": "zai",
|
||||
|
|
@ -316,6 +320,7 @@
|
|||
"provider": "zai",
|
||||
"method": "cn",
|
||||
"choiceId": "zai-cn",
|
||||
"appGuidedSecret": true,
|
||||
"choiceLabel": "CN",
|
||||
"choiceHint": "Z.AI CN (open.bigmodel.cn)",
|
||||
"groupId": "zai",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,18 @@ export const CrestodianSetupDetectResultSchema = Type.Object(
|
|||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
/** Text-inference key/token methods exposed by the Gateway provider registry. */
|
||||
manualProviders: Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
/** Opaque provider-auth choice sent back during activation. */
|
||||
id: NonEmptyString,
|
||||
label: NonEmptyString,
|
||||
hint: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
workspace: NonEmptyString,
|
||||
configuredModel: Type.Optional(Type.String()),
|
||||
setupComplete: Type.Boolean(),
|
||||
|
|
@ -90,9 +102,9 @@ export const CrestodianSetupActivateParamsSchema = Type.Object(
|
|||
Type.Literal("gemini-cli"),
|
||||
Type.Literal("api-key"),
|
||||
]),
|
||||
/** Manual step only: provider the pasted key belongs to (anthropic/openai/google). */
|
||||
provider: Type.Optional(Type.String()),
|
||||
/** Manual step only: the pasted API key; masked by clients, never echoed. */
|
||||
/** Manual step only: opaque provider-auth choice returned by detection. */
|
||||
authChoice: Type.Optional(Type.String()),
|
||||
/** Manual step only: the pasted API key or token; masked by clients, never echoed. */
|
||||
apiKey: Type.Optional(Type.String()),
|
||||
workspace: Type.Optional(Type.String()),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,19 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { activateSetupInference, detectSetupInference } from "./setup-inference.js";
|
||||
import {
|
||||
readAuthProfileStoreForTest,
|
||||
removeOAuthTestTempRoot,
|
||||
} from "../agents/auth-profiles/oauth-test-utils.js";
|
||||
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { ProviderAuthChoiceMetadata } from "../plugins/provider-auth-choices.js";
|
||||
import type { ProviderPlugin } from "../plugins/types.js";
|
||||
import {
|
||||
activateSetupInference,
|
||||
detectSetupInference,
|
||||
listSetupInferenceManualProviders,
|
||||
} from "./setup-inference.js";
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: vi.fn(async () => ({
|
||||
|
|
@ -45,12 +57,82 @@ async function makeTempDir(): Promise<string> {
|
|||
|
||||
describe("detectSetupInference", () => {
|
||||
it("marks the first non-logged-out candidate recommended", async () => {
|
||||
const detection = await detectSetupInference();
|
||||
const resolveManifestProviderAuthChoices = vi.fn(() => []);
|
||||
const detection = await detectSetupInference({ resolveManifestProviderAuthChoices });
|
||||
expect(detection.candidates).toHaveLength(2);
|
||||
expect(detection.candidates[0]).toMatchObject({ kind: "claude-cli", recommended: true });
|
||||
expect(detection.candidates[1]).toMatchObject({ kind: "codex-cli", recommended: false });
|
||||
expect(detection.setupComplete).toBe(false);
|
||||
expect(detection.workspace.length).toBeGreaterThan(0);
|
||||
expect(resolveManifestProviderAuthChoices).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeWorkspacePlugins: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists text-inference key and token methods from provider manifests", () => {
|
||||
const choices: ProviderAuthChoiceMetadata[] = [
|
||||
{
|
||||
pluginId: "visuals",
|
||||
providerId: "visuals",
|
||||
methodId: "api-key",
|
||||
choiceId: "visuals-api-key",
|
||||
choiceLabel: "Visuals API key",
|
||||
appGuidedSecret: true,
|
||||
onboardingScopes: ["image-generation"],
|
||||
},
|
||||
{
|
||||
pluginId: "zeta",
|
||||
providerId: "zeta",
|
||||
methodId: "oauth",
|
||||
choiceId: "zeta-oauth",
|
||||
choiceLabel: "Zeta OAuth",
|
||||
},
|
||||
{
|
||||
pluginId: "zeta",
|
||||
providerId: "zeta",
|
||||
methodId: "direct-key",
|
||||
choiceId: "zeta-api-key",
|
||||
choiceLabel: "Zeta API key",
|
||||
choiceHint: "Direct key",
|
||||
optionKey: "zetaApiKey",
|
||||
cliOption: "--zeta-api-key <key>",
|
||||
appGuidedSecret: true,
|
||||
},
|
||||
{
|
||||
pluginId: "alpha",
|
||||
providerId: "alpha",
|
||||
methodId: "api-key",
|
||||
choiceId: "alpha-api-key",
|
||||
choiceLabel: "Alpha API key",
|
||||
appGuidedSecret: true,
|
||||
},
|
||||
{
|
||||
pluginId: "github-copilot",
|
||||
providerId: "github-copilot",
|
||||
methodId: "device",
|
||||
choiceId: "github-copilot",
|
||||
choiceLabel: "GitHub Copilot",
|
||||
optionKey: "githubCopilotToken",
|
||||
cliOption: "--github-copilot-token <token>",
|
||||
appGuidedSecret: true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(listSetupInferenceManualProviders(choices)).toEqual([
|
||||
{
|
||||
id: "alpha-api-key",
|
||||
label: "Alpha API key",
|
||||
},
|
||||
{
|
||||
id: "github-copilot",
|
||||
label: "GitHub Copilot",
|
||||
},
|
||||
{
|
||||
id: "zeta-api-key",
|
||||
label: "Zeta API key",
|
||||
hint: "Direct key",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -132,15 +214,344 @@ describe("activateSetupInference", () => {
|
|||
it("rejects manual activation without a supported provider", async () => {
|
||||
const result = await activateSetupInference({
|
||||
kind: "api-key",
|
||||
provider: "definitely-not-a-provider",
|
||||
authChoice: "definitely-not-a-provider",
|
||||
apiKey: "sk-test",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: { createTempDir: makeTempDir },
|
||||
deps: {
|
||||
createTempDir: makeTempDir,
|
||||
resolveManifestProviderAuthChoice: () => undefined,
|
||||
resolvePluginProviders: () => [],
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, status: "unavailable" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "API-key", authKind: "api_key" as const, credentialType: "api_key" as const },
|
||||
{ name: "token", authKind: "token" as const, credentialType: "token" as const },
|
||||
])(
|
||||
"uses a provider-owned $name method and persists it after a passing test",
|
||||
async ({ authKind, credentialType }) => {
|
||||
const stateDir = await makeTempDir();
|
||||
const agentDir = path.join(stateDir, "agent");
|
||||
const runAuth = vi.fn(async (ctx: { opts?: { token?: string } }) => ({
|
||||
profiles: [
|
||||
{
|
||||
profileId: "groq:default",
|
||||
credential:
|
||||
credentialType === "api_key"
|
||||
? { type: "api_key" as const, provider: "groq", key: ctx.opts?.token }
|
||||
: { type: "token" as const, provider: "groq", token: ctx.opts?.token ?? "" },
|
||||
},
|
||||
],
|
||||
defaultModel: "groq/llama-3.3-70b-versatile",
|
||||
configPatch: { agents: { defaults: { models: { "groq/llama-3.3-70b-versatile": {} } } } },
|
||||
}));
|
||||
const provider: ProviderPlugin = {
|
||||
id: "groq",
|
||||
label: "Groq",
|
||||
pluginId: "groq",
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
label: "Groq API key",
|
||||
kind: authKind,
|
||||
wizard: { choiceId: "groq-api-key" },
|
||||
run: runAuth as never,
|
||||
},
|
||||
],
|
||||
};
|
||||
const resolvePluginProviders = vi.fn(() => [provider]);
|
||||
const enablePluginInConfig = vi.fn((config: OpenClawConfig, pluginId: string) => ({
|
||||
config: {
|
||||
...config,
|
||||
plugins: { entries: { [pluginId]: { enabled: true } } },
|
||||
},
|
||||
enabled: true,
|
||||
}));
|
||||
const runEmbeddedAgent = vi.fn(async () => ({
|
||||
meta: { finalAssistantVisibleText: "OK" },
|
||||
}));
|
||||
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: ["ok"] }));
|
||||
let persistedConfig: OpenClawConfig = {};
|
||||
const updateConfig = vi.fn(async (mutator: (cfg: OpenClawConfig) => OpenClawConfig) => {
|
||||
persistedConfig = mutator(persistedConfig);
|
||||
return persistedConfig;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await activateSetupInference({
|
||||
kind: "api-key",
|
||||
authChoice: "groq-api-key",
|
||||
apiKey: "test-groq-key",
|
||||
workspace: "/tmp/openclaw-workspace",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
resolvePluginProviders,
|
||||
enablePluginInConfig: enablePluginInConfig as never,
|
||||
resolveManifestProviderAuthChoice: () => ({
|
||||
pluginId: "groq",
|
||||
providerId: "groq",
|
||||
methodId: "api-key",
|
||||
choiceId: "groq-api-key",
|
||||
choiceLabel: "Groq API key",
|
||||
appGuidedSecret: true,
|
||||
}),
|
||||
resolveAgentDir: () => agentDir,
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
updateConfig: updateConfig as never,
|
||||
applySetup: applySetup as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: true, modelRef: "groq/llama-3.3-70b-versatile" });
|
||||
expect(resolvePluginProviders).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
plugins: { entries: { groq: { enabled: true } } },
|
||||
}),
|
||||
onlyPluginIds: ["groq"],
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
}),
|
||||
);
|
||||
expect(runAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
opts: expect.objectContaining({ token: "test-groq-key", tokenProvider: "groq" }),
|
||||
allowSecretRefPrompt: false,
|
||||
secretInputMode: "plaintext",
|
||||
}),
|
||||
);
|
||||
expect(runEmbeddedAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "groq",
|
||||
model: "llama-3.3-70b-versatile",
|
||||
authProfileId: "groq:default",
|
||||
agentDir: expect.stringContaining("setup-inference-test-"),
|
||||
}),
|
||||
);
|
||||
expect(persistedConfig).toMatchObject({
|
||||
plugins: { entries: { groq: { enabled: true } } },
|
||||
auth: { profiles: { "groq:default": { provider: "groq", mode: credentialType } } },
|
||||
});
|
||||
expect(readAuthProfileStoreForTest(agentDir).profiles["groq:default"]).toMatchObject(
|
||||
credentialType === "api_key"
|
||||
? { type: "api_key", provider: "groq", key: "test-groq-key" }
|
||||
: { type: "token", provider: "groq", token: "test-groq-key" },
|
||||
);
|
||||
} finally {
|
||||
await removeOAuthTestTempRoot(stateDir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "uses a provider starter model instead of an unrelated existing default",
|
||||
existingModel: "openai/gpt-5.2",
|
||||
starterModel: "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
{
|
||||
name: "accepts an unchanged provider-owned dynamic model",
|
||||
existingModel: "github-copilot/claude-sonnet-4.5",
|
||||
starterModel: undefined,
|
||||
},
|
||||
])("$name without starting interactive login", async ({ existingModel, starterModel }) => {
|
||||
const stateDir = await makeTempDir();
|
||||
const agentDir = path.join(stateDir, "agent");
|
||||
const runInteractive = vi.fn();
|
||||
const runNonInteractive = vi.fn(
|
||||
async (ctx: {
|
||||
agentDir?: string;
|
||||
opts: { githubCopilotToken?: unknown };
|
||||
config: OpenClawConfig;
|
||||
}) => {
|
||||
const token =
|
||||
typeof ctx.opts.githubCopilotToken === "string" ? ctx.opts.githubCopilotToken : "";
|
||||
await upsertAuthProfileWithLock({
|
||||
profileId: "github-copilot:github",
|
||||
credential: { type: "token", provider: "github-copilot", token },
|
||||
agentDir: ctx.agentDir,
|
||||
});
|
||||
return {
|
||||
...ctx.config,
|
||||
agents: {
|
||||
...ctx.config.agents,
|
||||
defaults: {
|
||||
...ctx.config.agents?.defaults,
|
||||
model: ctx.config.agents?.defaults?.model ?? {
|
||||
primary: "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
},
|
||||
);
|
||||
const provider: ProviderPlugin = {
|
||||
id: "github-copilot",
|
||||
label: "GitHub Copilot",
|
||||
pluginId: "github-copilot",
|
||||
auth: [
|
||||
{
|
||||
id: "device",
|
||||
label: "GitHub device login",
|
||||
kind: "device_code",
|
||||
...(starterModel ? { starterModel } : {}),
|
||||
run: runInteractive as never,
|
||||
runNonInteractive: runNonInteractive as never,
|
||||
},
|
||||
],
|
||||
};
|
||||
const runEmbeddedAgent = vi.fn(async () => ({
|
||||
meta: { finalAssistantVisibleText: "OK" },
|
||||
}));
|
||||
const initialConfig = {
|
||||
gateway: { port: 18789 },
|
||||
agents: { defaults: { model: { primary: existingModel } } },
|
||||
} satisfies OpenClawConfig;
|
||||
let persistedConfig: OpenClawConfig = {
|
||||
gateway: { port: 19000 },
|
||||
agents: { defaults: { model: { primary: existingModel } } },
|
||||
} satisfies OpenClawConfig;
|
||||
const updateConfig = vi.fn(async (mutator: (cfg: OpenClawConfig) => OpenClawConfig) => {
|
||||
persistedConfig = mutator(persistedConfig);
|
||||
return persistedConfig;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await activateSetupInference({
|
||||
kind: "api-key",
|
||||
authChoice: "github-copilot",
|
||||
apiKey: "github-token",
|
||||
workspace: "/tmp/openclaw-workspace",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
readConfigFileSnapshot: vi.fn(async () => ({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: initialConfig,
|
||||
runtimeConfig: initialConfig,
|
||||
})) as never,
|
||||
resolvePluginProviders: () => [provider],
|
||||
resolveManifestProviderAuthChoice: () => ({
|
||||
pluginId: "github-copilot",
|
||||
providerId: "github-copilot",
|
||||
methodId: "device",
|
||||
choiceId: "github-copilot",
|
||||
choiceLabel: "GitHub Copilot",
|
||||
optionKey: "githubCopilotToken",
|
||||
cliOption: "--github-copilot-token <token>",
|
||||
appGuidedSecret: true,
|
||||
}),
|
||||
resolveAgentDir: () => agentDir,
|
||||
runEmbeddedAgent: runEmbeddedAgent as never,
|
||||
updateConfig: updateConfig as never,
|
||||
applySetup: vi.fn(async () => ({
|
||||
configPath: "/tmp/openclaw.json",
|
||||
lines: ["ok"],
|
||||
})) as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
modelRef: "github-copilot/claude-sonnet-4.5",
|
||||
});
|
||||
expect(runInteractive).not.toHaveBeenCalled();
|
||||
expect(runNonInteractive).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
opts: expect.objectContaining({ githubCopilotToken: "github-token" }),
|
||||
}),
|
||||
);
|
||||
expect(runEmbeddedAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentDir: expect.stringContaining("setup-inference-test-"),
|
||||
authProfileId: "github-copilot:github",
|
||||
provider: "github-copilot",
|
||||
model: "claude-sonnet-4.5",
|
||||
}),
|
||||
);
|
||||
expect(readAuthProfileStoreForTest(agentDir).profiles["github-copilot:github"]).toMatchObject(
|
||||
{
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "github-token",
|
||||
},
|
||||
);
|
||||
expect(persistedConfig.gateway?.port).toBe(19000);
|
||||
expect(persistedConfig.agents?.defaults?.model).toEqual({ primary: existingModel });
|
||||
} finally {
|
||||
await removeOAuthTestTempRoot(stateDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not persist a provider key after a failed live test", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
const agentDir = path.join(stateDir, "agent");
|
||||
const provider: ProviderPlugin = {
|
||||
id: "groq",
|
||||
label: "Groq",
|
||||
pluginId: "groq",
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
label: "Groq API key",
|
||||
kind: "api_key",
|
||||
wizard: { choiceId: "groq-api-key" },
|
||||
run: async (ctx) => ({
|
||||
profiles: [
|
||||
{
|
||||
profileId: "groq:default",
|
||||
credential: { type: "api_key", provider: "groq", key: ctx.opts?.token },
|
||||
},
|
||||
],
|
||||
defaultModel: "groq/llama-3.3-70b-versatile",
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await activateSetupInference({
|
||||
kind: "api-key",
|
||||
authChoice: "groq-api-key",
|
||||
apiKey: "bad-groq-key",
|
||||
workspace: "/tmp/openclaw-workspace",
|
||||
surface: "gateway",
|
||||
runtime,
|
||||
deps: {
|
||||
resolvePluginProviders: () => [provider],
|
||||
resolveManifestProviderAuthChoice: () => ({
|
||||
pluginId: "groq",
|
||||
providerId: "groq",
|
||||
methodId: "api-key",
|
||||
choiceId: "groq-api-key",
|
||||
choiceLabel: "Groq API key",
|
||||
appGuidedSecret: true,
|
||||
}),
|
||||
resolveAgentDir: () => agentDir,
|
||||
runEmbeddedAgent: vi.fn(async () => {
|
||||
throw new Error("401 invalid_api_key");
|
||||
}) as never,
|
||||
applySetup: vi.fn() as never,
|
||||
updateConfig: vi.fn() as never,
|
||||
createTempDir: makeTempDir,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false, status: "auth" });
|
||||
expect(readAuthProfileStoreForTest(agentDir).profiles["groq:default"]).toBeUndefined();
|
||||
} finally {
|
||||
await removeOAuthTestTempRoot(stateDir);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs the codex plugin ensure step only after a passing test", async () => {
|
||||
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: ["ok"] }));
|
||||
const ensureCodex = vi.fn(async () => ({
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
|
||||
import { normalizeAuthProfileCredential } from "../agents/auth-profiles/credential-normalize.js";
|
||||
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
|
||||
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
|
||||
import type { AuthProfileCredential } from "../agents/auth-profiles/types.js";
|
||||
import { describeFailoverError } from "../agents/failover-error.js";
|
||||
import {
|
||||
isCliProvider,
|
||||
|
|
@ -22,7 +22,25 @@ import {
|
|||
detectInferenceBackends,
|
||||
type InferenceBackendKind,
|
||||
} from "../commands/onboard-inference.js";
|
||||
import { createMergePatch } from "../config/io.write-prepare.js";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import {
|
||||
normalizeAgentModelRefForConfig,
|
||||
resolveAgentModelPrimaryValue,
|
||||
} from "../config/model-input.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import {
|
||||
applyProviderPluginAuthMethodResultConfig,
|
||||
runProviderPluginAuthMethodUnpersisted,
|
||||
} from "../plugins/provider-auth-choice.js";
|
||||
import {
|
||||
resolveManifestProviderAuthChoice,
|
||||
resolveManifestProviderAuthChoices,
|
||||
type ProviderAuthChoiceMetadata,
|
||||
} from "../plugins/provider-auth-choices.js";
|
||||
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
|
||||
import type { ProviderAuthMethod, ProviderAuthResult } from "../plugins/types.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { buildCliPlannerConfig, buildCodexAppServerPlannerConfig } from "./assistant-backends.js";
|
||||
|
|
@ -39,14 +57,6 @@ import { applyCrestodianSetup, createQuickstartNotePrompter } from "./setup-appl
|
|||
export const SETUP_INFERENCE_TEST_TIMEOUT_MS = 90_000;
|
||||
const SETUP_INFERENCE_TEST_PROMPT = "Reply with the single word OK. Do not use tools.";
|
||||
const SETUP_INFERENCE_TEST_MAX_TOKENS = 32;
|
||||
const GOOGLE_API_DEFAULT_MODEL_REF = "google/gemini-3.1-pro-preview";
|
||||
|
||||
/** Providers accepted for the manual API-key step, mapped to a starter model. */
|
||||
const MANUAL_API_KEY_MODEL_REFS: Record<string, string> = {
|
||||
anthropic: ANTHROPIC_API_DEFAULT_MODEL_REF,
|
||||
openai: OPENAI_API_DEFAULT_MODEL_REF,
|
||||
google: GOOGLE_API_DEFAULT_MODEL_REF,
|
||||
};
|
||||
|
||||
export type SetupInferenceCandidate = {
|
||||
kind: InferenceBackendKind;
|
||||
|
|
@ -57,8 +67,17 @@ export type SetupInferenceCandidate = {
|
|||
credentials?: boolean;
|
||||
};
|
||||
|
||||
export type SetupInferenceManualProvider = {
|
||||
/** Provider-auth choice id sent back to `crestodian.setup.activate`. */
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export type SetupInferenceDetection = {
|
||||
candidates: SetupInferenceCandidate[];
|
||||
/** Text-inference key/token methods exposed by installed provider manifests. */
|
||||
manualProviders: SetupInferenceManualProvider[];
|
||||
/** Resolved workspace the setup apply would use (display + default). */
|
||||
workspace: string;
|
||||
configuredModel?: string;
|
||||
|
|
@ -82,9 +101,9 @@ export type ActivateSetupInferenceResult =
|
|||
|
||||
export type ActivateSetupInferenceParams = {
|
||||
kind: InferenceBackendKind | "api-key";
|
||||
/** Manual step only: provider the pasted API key belongs to. */
|
||||
provider?: string;
|
||||
/** Manual step only: the pasted API key. Never logged. */
|
||||
/** Manual step only: provider-auth choice returned by detection. */
|
||||
authChoice?: string;
|
||||
/** Manual step only: the pasted API key or token. Never logged. */
|
||||
apiKey?: string;
|
||||
workspace?: string;
|
||||
surface: "cli" | "gateway";
|
||||
|
|
@ -99,12 +118,64 @@ export type ActivateSetupInferenceDeps = {
|
|||
applySetup?: typeof applyCrestodianSetup;
|
||||
ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection;
|
||||
updateConfig?: typeof import("../commands/models/shared.js").updateConfig;
|
||||
resolvePluginProviders?: typeof resolvePluginProviders;
|
||||
resolveManifestProviderAuthChoice?: typeof resolveManifestProviderAuthChoice;
|
||||
enablePluginInConfig?: typeof enablePluginInConfig;
|
||||
resolveAgentDir?: typeof resolveAgentDir;
|
||||
createTempDir?: () => Promise<string>;
|
||||
removeTempDir?: (dir: string) => Promise<void>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export async function detectSetupInference(): Promise<SetupInferenceDetection> {
|
||||
export type DetectSetupInferenceDeps = {
|
||||
resolveManifestProviderAuthChoices?: typeof resolveManifestProviderAuthChoices;
|
||||
};
|
||||
|
||||
async function resolveSetupInferenceWorkspace(params: {
|
||||
configExists: boolean;
|
||||
configValid: boolean;
|
||||
}): Promise<{ workspace: string; hasAuthoredSetup: boolean }> {
|
||||
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig(params);
|
||||
const { DEFAULT_WORKSPACE } = await import("../commands/onboard-helpers.js");
|
||||
return {
|
||||
workspace: resolveUserPath(
|
||||
authoredConfig?.agents?.defaults?.workspace?.trim() || DEFAULT_WORKSPACE,
|
||||
),
|
||||
hasAuthoredSetup,
|
||||
};
|
||||
}
|
||||
|
||||
function supportsTextInference(scopes?: ProviderAuthChoiceMetadata["onboardingScopes"]): boolean {
|
||||
return !scopes || scopes.includes("text-inference");
|
||||
}
|
||||
|
||||
function supportsManualSecret(choice: ProviderAuthChoiceMetadata): boolean {
|
||||
return supportsTextInference(choice.onboardingScopes) && choice.appGuidedSecret === true;
|
||||
}
|
||||
|
||||
export function listSetupInferenceManualProviders(
|
||||
authChoices: readonly ProviderAuthChoiceMetadata[],
|
||||
): SetupInferenceManualProvider[] {
|
||||
const choices = new Map<string, SetupInferenceManualProvider>();
|
||||
for (const choice of authChoices) {
|
||||
const id = choice.choiceId.trim();
|
||||
if (!id || choices.has(id) || !supportsManualSecret(choice)) {
|
||||
continue;
|
||||
}
|
||||
choices.set(id, {
|
||||
id,
|
||||
label: choice.choiceLabel,
|
||||
...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}),
|
||||
});
|
||||
}
|
||||
return [...choices.values()].toSorted(
|
||||
(a, b) => a.label.localeCompare(b.label, "en") || a.id.localeCompare(b.id, "en"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function detectSetupInference(
|
||||
deps: DetectSetupInferenceDeps = {},
|
||||
): Promise<SetupInferenceDetection> {
|
||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const cfg = snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
|
||||
|
|
@ -116,17 +187,22 @@ export async function detectSetupInference(): Promise<SetupInferenceDetection> {
|
|||
...candidate,
|
||||
recommended: index === recommendedIndex,
|
||||
}));
|
||||
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig({
|
||||
const { workspace, hasAuthoredSetup } = await resolveSetupInferenceWorkspace({
|
||||
configExists: snapshot.exists,
|
||||
configValid: snapshot.valid,
|
||||
});
|
||||
const configuredModel = raw.find((candidate) => candidate.kind === "existing-model")?.modelRef;
|
||||
const { DEFAULT_WORKSPACE } = await import("../commands/onboard-helpers.js");
|
||||
const workspace = resolveUserPath(
|
||||
authoredConfig?.agents?.defaults?.workspace?.trim() || DEFAULT_WORKSPACE,
|
||||
);
|
||||
const authChoices = (
|
||||
deps.resolveManifestProviderAuthChoices ?? resolveManifestProviderAuthChoices
|
||||
)({
|
||||
config: cfg,
|
||||
workspaceDir: workspace,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
includeWorkspacePlugins: false,
|
||||
}).filter((choice) => enablePluginInConfig(cfg, choice.pluginId).enabled);
|
||||
return {
|
||||
candidates,
|
||||
manualProviders: listSetupInferenceManualProviders(authChoices),
|
||||
workspace,
|
||||
...(configuredModel ? { configuredModel } : {}),
|
||||
setupComplete: hasAuthoredSetup && Boolean(configuredModel),
|
||||
|
|
@ -140,9 +216,15 @@ type SetupInferenceTestPlan = {
|
|||
modelRef: string;
|
||||
config: OpenClawConfig;
|
||||
agentHarnessId?: string;
|
||||
agentDir?: string;
|
||||
authProfileId?: string;
|
||||
/** Model to persist as default on success; undefined keeps the current one. */
|
||||
persistModelRef?: string;
|
||||
manualAuth?: {
|
||||
profiles: ProviderAuthResult["profiles"];
|
||||
configPatch: unknown;
|
||||
pluginId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type RunResult = {
|
||||
|
|
@ -189,9 +271,14 @@ function mapFailoverReasonToSetupStatus(reason?: string | null): SetupInferenceS
|
|||
|
||||
async function buildTestPlan(params: {
|
||||
kind: InferenceBackendKind | "api-key";
|
||||
provider?: string;
|
||||
authChoice?: string;
|
||||
apiKey?: string;
|
||||
cfg: OpenClawConfig;
|
||||
workspaceDir: string;
|
||||
pluginWorkspaceDir: string;
|
||||
agentDir: string;
|
||||
runtime: RuntimeEnv;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
}): Promise<SetupInferenceTestPlan | { error: string }> {
|
||||
const { kind, cfg, workspaceDir } = params;
|
||||
switch (kind) {
|
||||
|
|
@ -258,22 +345,120 @@ async function buildTestPlan(params: {
|
|||
};
|
||||
}
|
||||
case "api-key": {
|
||||
const provider = normalizeProviderId(params.provider ?? "");
|
||||
const canonical = provider === "codex" || provider === "openai-codex" ? "openai" : provider;
|
||||
const modelRef = MANUAL_API_KEY_MODEL_REFS[canonical];
|
||||
if (!modelRef) {
|
||||
const apiKey = params.apiKey?.trim();
|
||||
if (!apiKey) {
|
||||
return { error: "Enter an API key or token first." };
|
||||
}
|
||||
const authChoice = params.authChoice?.trim();
|
||||
const choice = authChoice
|
||||
? (params.deps.resolveManifestProviderAuthChoice ?? resolveManifestProviderAuthChoice)(
|
||||
authChoice,
|
||||
{
|
||||
config: cfg,
|
||||
workspaceDir: params.pluginWorkspaceDir,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
includeWorkspacePlugins: false,
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
if (!choice || !supportsManualSecret(choice)) {
|
||||
return { error: "That key-based provider is not available on this Gateway." };
|
||||
}
|
||||
const enableResult = (params.deps.enablePluginInConfig ?? enablePluginInConfig)(
|
||||
cfg,
|
||||
choice.pluginId,
|
||||
);
|
||||
if (!enableResult.enabled) {
|
||||
return {
|
||||
error: `Unsupported provider "${params.provider ?? ""}" — expected anthropic, openai, or google.`,
|
||||
error: `${choice.choiceLabel} is disabled (${enableResult.reason ?? "blocked"}).`,
|
||||
};
|
||||
}
|
||||
const providers = (params.deps.resolvePluginProviders ?? resolvePluginProviders)({
|
||||
config: enableResult.config,
|
||||
workspaceDir: params.pluginWorkspaceDir,
|
||||
mode: "setup",
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
onlyPluginIds: [choice.pluginId],
|
||||
});
|
||||
const provider = providers.find(
|
||||
(candidate) =>
|
||||
candidate.pluginId === choice.pluginId &&
|
||||
normalizeProviderId(candidate.id) === normalizeProviderId(choice.providerId),
|
||||
);
|
||||
const method = provider?.auth.find((candidate) => candidate.id === choice.methodId);
|
||||
const resolved = provider && method ? { provider, method } : null;
|
||||
if (!resolved || !supportsTextInference(resolved.method.wizard?.onboardingScopes)) {
|
||||
return { error: "That key-based provider is not available on this Gateway." };
|
||||
}
|
||||
let result: ProviderAuthResult;
|
||||
let preparedConfig: OpenClawConfig;
|
||||
try {
|
||||
if (resolved.method.kind === "api_key" || resolved.method.kind === "token") {
|
||||
result = await runProviderPluginAuthMethodUnpersisted({
|
||||
config: enableResult.config,
|
||||
runtime: params.runtime,
|
||||
prompter: createQuickstartNotePrompter(params.runtime),
|
||||
method: resolved.method,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir,
|
||||
secretInputMode: "plaintext",
|
||||
allowSecretRefPrompt: false,
|
||||
opts: { token: apiKey, tokenProvider: resolved.provider.id },
|
||||
});
|
||||
preparedConfig = applyProviderPluginAuthMethodResultConfig({
|
||||
config: enableResult.config,
|
||||
result,
|
||||
});
|
||||
} else {
|
||||
const prepared = await runProviderManualSecretMethod({
|
||||
config: enableResult.config,
|
||||
baseConfig: cfg,
|
||||
choice,
|
||||
method: resolved.method,
|
||||
apiKey,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir,
|
||||
});
|
||||
result = prepared.result;
|
||||
preparedConfig = prepared.config;
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
error: `${resolved.provider.label} could not prepare this credential for app-guided setup.`,
|
||||
};
|
||||
}
|
||||
const modelRef = result.defaultModel
|
||||
? normalizeAgentModelRefForConfig(result.defaultModel)
|
||||
: "";
|
||||
if (!modelRef || result.profiles.length === 0) {
|
||||
return {
|
||||
error: `${resolved.provider.label} does not expose a starter model for app-guided setup.`,
|
||||
};
|
||||
}
|
||||
const ref = parseRef(modelRef);
|
||||
if (!ref.model) {
|
||||
return {
|
||||
error: `${resolved.provider.label} returned an invalid starter model.`,
|
||||
};
|
||||
}
|
||||
const matchingProfile =
|
||||
result.profiles.find(
|
||||
(profile) =>
|
||||
normalizeProviderId(profile.credential.provider) === normalizeProviderId(ref.provider),
|
||||
) ?? result.profiles[0];
|
||||
return {
|
||||
runner: "embedded",
|
||||
...ref,
|
||||
modelRef,
|
||||
config: buildCliPlannerConfig(workspaceDir, modelRef),
|
||||
authProfileId: `${canonical}:manual`,
|
||||
agentDir: params.agentDir,
|
||||
config: preparedConfig,
|
||||
authProfileId: matchingProfile.profileId,
|
||||
persistModelRef: modelRef,
|
||||
manualAuth: {
|
||||
profiles: result.profiles,
|
||||
configPatch: createMergePatch(enableResult.config, preparedConfig),
|
||||
...(resolved.provider.pluginId ? { pluginId: resolved.provider.pluginId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
|
|
@ -281,10 +466,87 @@ async function buildTestPlan(params: {
|
|||
}
|
||||
}
|
||||
|
||||
async function runProviderManualSecretMethod(params: {
|
||||
config: OpenClawConfig;
|
||||
baseConfig: OpenClawConfig;
|
||||
choice: ProviderAuthChoiceMetadata;
|
||||
method: ProviderAuthMethod;
|
||||
apiKey: string;
|
||||
agentDir: string;
|
||||
workspaceDir: string;
|
||||
}): Promise<{ result: ProviderAuthResult; config: OpenClawConfig }> {
|
||||
const optionKey = params.choice.optionKey;
|
||||
const runNonInteractive = params.method.runNonInteractive;
|
||||
if (!optionKey || !params.choice.cliOption || !runNonInteractive) {
|
||||
throw new Error("Provider does not expose app-guided secret setup.");
|
||||
}
|
||||
|
||||
let methodError = "";
|
||||
const isolatedRuntime: RuntimeEnv = {
|
||||
log: () => {},
|
||||
error: (...args) => {
|
||||
methodError = args.map(String).join(" ");
|
||||
},
|
||||
// Provider CLI methods use exit for validation failures. Convert it to a
|
||||
// request-local failure so app-guided setup can never stop the Gateway.
|
||||
exit: (code) => {
|
||||
throw new Error(methodError || `Provider setup exited with code ${code}.`);
|
||||
},
|
||||
};
|
||||
const configured = await runNonInteractive({
|
||||
authChoice: params.choice.choiceId,
|
||||
config: params.config,
|
||||
baseConfig: params.baseConfig,
|
||||
opts: { [optionKey]: params.apiKey, secretInputMode: "plaintext" },
|
||||
runtime: isolatedRuntime,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
resolveApiKey: async (input) =>
|
||||
typeof input.flagValue === "string" && input.flagValue.trim()
|
||||
? { key: input.flagValue.trim(), source: "flag" }
|
||||
: null,
|
||||
toApiKeyCredential: ({ provider, resolved, email, metadata }) => ({
|
||||
type: "api_key",
|
||||
provider,
|
||||
key: resolved.key,
|
||||
...(email ? { email } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
}),
|
||||
});
|
||||
if (!configured) {
|
||||
throw new Error(methodError || "Provider setup did not produce a configuration.");
|
||||
}
|
||||
|
||||
const store = loadPersistedAuthProfileStore(params.agentDir);
|
||||
const profiles = Object.entries(store?.profiles ?? {}).map(([profileId, credential]) => ({
|
||||
profileId,
|
||||
credential,
|
||||
}));
|
||||
const previousModel = resolveAgentModelPrimaryValue(params.config.agents?.defaults?.model);
|
||||
const configuredModel = resolveAgentModelPrimaryValue(configured.agents?.defaults?.model);
|
||||
const configuredProvider = configuredModel ? parseRef(configuredModel).provider : undefined;
|
||||
// Dynamic provider setup can rediscover the already-selected model while
|
||||
// repairing credentials. It is valid only when the provider still owns it.
|
||||
const configuredModelOwnedByProvider =
|
||||
configuredProvider !== undefined &&
|
||||
normalizeProviderId(configuredProvider) === normalizeProviderId(params.choice.providerId);
|
||||
const defaultModel =
|
||||
configuredModel && (configuredModel !== previousModel || configuredModelOwnedByProvider)
|
||||
? configuredModel
|
||||
: params.method.starterModel;
|
||||
if (profiles.length === 0 || !defaultModel) {
|
||||
throw new Error("Provider setup did not produce credentials and a starter model.");
|
||||
}
|
||||
return {
|
||||
result: { profiles, defaultModel },
|
||||
config: configured,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test one candidate with a real completion, then persist it as the setup
|
||||
* default. Manual API keys are staged into the auth store for the test and
|
||||
* rolled back when the test fails, so a bad key leaves no trace.
|
||||
* default. Manual credentials are tested from a temporary auth store and
|
||||
* copied into the real agent store only after success, so failures leave no trace.
|
||||
*/
|
||||
export async function activateSetupInference(
|
||||
params: ActivateSetupInferenceParams,
|
||||
|
|
@ -295,35 +557,39 @@ export async function activateSetupInference(
|
|||
const snapshot = await readSnapshot();
|
||||
const cfg: OpenClawConfig =
|
||||
snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
|
||||
const workspace = params.workspace?.trim()
|
||||
? resolveUserPath(params.workspace)
|
||||
: (
|
||||
await resolveSetupInferenceWorkspace({
|
||||
configExists: snapshot.exists,
|
||||
configValid: snapshot.valid,
|
||||
})
|
||||
).workspace;
|
||||
|
||||
const tempDir = await (
|
||||
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
|
||||
)();
|
||||
const agentDir = (deps.resolveAgentDir ?? resolveAgentDir)(cfg, resolveDefaultAgentId(cfg));
|
||||
const testAgentDir = path.join(tempDir, "agent");
|
||||
try {
|
||||
const plan = await buildTestPlan({
|
||||
kind: params.kind,
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
...(params.authChoice !== undefined ? { authChoice: params.authChoice } : {}),
|
||||
...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}),
|
||||
cfg,
|
||||
workspaceDir: tempDir,
|
||||
pluginWorkspaceDir: workspace,
|
||||
agentDir: testAgentDir,
|
||||
runtime: params.runtime,
|
||||
deps,
|
||||
});
|
||||
if ("error" in plan) {
|
||||
return { ok: false, status: "unavailable", error: plan.error };
|
||||
}
|
||||
|
||||
const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg));
|
||||
let stagedProfile: { profileId: string; prior?: AuthProfileCredential } | null = null;
|
||||
if (plan.authProfileId) {
|
||||
const apiKey = params.apiKey?.trim();
|
||||
if (!apiKey) {
|
||||
return { ok: false, status: "unavailable", error: "Enter an API key first." };
|
||||
}
|
||||
stagedProfile = await stageManualApiKeyProfile({
|
||||
profileId: plan.authProfileId,
|
||||
provider: plan.provider,
|
||||
apiKey,
|
||||
agentDir,
|
||||
});
|
||||
if (!stagedProfile) {
|
||||
if (plan.manualAuth) {
|
||||
const staged = await persistManualAuthProfiles(plan.manualAuth.profiles, testAgentDir);
|
||||
if (!staged) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
|
|
@ -334,9 +600,6 @@ export async function activateSetupInference(
|
|||
|
||||
const test = await runSetupInferenceTest({ plan, tempDir, deps });
|
||||
if (!test.ok) {
|
||||
if (stagedProfile) {
|
||||
await rollbackManualApiKeyProfile({ ...stagedProfile, agentDir });
|
||||
}
|
||||
return test;
|
||||
}
|
||||
|
||||
|
|
@ -357,27 +620,27 @@ export async function activateSetupInference(
|
|||
if (ensured.required) {
|
||||
const updateConfig =
|
||||
deps.updateConfig ?? (await import("../commands/models/shared.js")).updateConfig;
|
||||
const { enablePluginInConfig } = await import("../plugins/enable.js");
|
||||
await updateConfig((current) => enablePluginInConfig(current, "codex").config);
|
||||
}
|
||||
}
|
||||
if (stagedProfile && plan.authProfileId) {
|
||||
if (plan.manualAuth) {
|
||||
const manualAuth = plan.manualAuth;
|
||||
const persisted = await persistManualAuthProfiles(manualAuth.profiles, agentDir);
|
||||
if (!persisted) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "unknown",
|
||||
error: "Could not update the auth profile store; try again in a moment.",
|
||||
};
|
||||
}
|
||||
const updateConfig =
|
||||
deps.updateConfig ?? (await import("../commands/models/shared.js")).updateConfig;
|
||||
const { applyAuthProfileConfig } = await import("../plugins/provider-auth-helpers.js");
|
||||
const profileId = plan.authProfileId;
|
||||
const provider = plan.provider;
|
||||
await updateConfig((current) =>
|
||||
applyAuthProfileConfig(current, { profileId, provider, mode: "api_key" }),
|
||||
);
|
||||
await updateConfig((current) => applyManualAuthConfig(current, manualAuth));
|
||||
}
|
||||
|
||||
const applySetup = deps.applySetup ?? applyCrestodianSetup;
|
||||
const detection = params.workspace?.trim()
|
||||
? { workspace: resolveUserPath(params.workspace) }
|
||||
: { workspace: (await detectSetupInference()).workspace };
|
||||
const applied = await applySetup({
|
||||
workspace: detection.workspace,
|
||||
workspace,
|
||||
...(plan.persistModelRef ? { model: plan.persistModelRef } : {}),
|
||||
surface: params.surface,
|
||||
runtime: params.runtime,
|
||||
|
|
@ -390,52 +653,36 @@ export async function activateSetupInference(
|
|||
}
|
||||
}
|
||||
|
||||
async function stageManualApiKeyProfile(params: {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
apiKey: string;
|
||||
agentDir: string;
|
||||
}): Promise<{ profileId: string; prior?: AuthProfileCredential } | null> {
|
||||
let prior: AuthProfileCredential | undefined;
|
||||
const updated = await updateAuthProfileStoreWithLock({
|
||||
agentDir: params.agentDir,
|
||||
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
updater: (store) => {
|
||||
prior = store.profiles[params.profileId];
|
||||
return false;
|
||||
},
|
||||
});
|
||||
if (updated === null) {
|
||||
return null;
|
||||
function applyManualAuthConfig(
|
||||
config: OpenClawConfig,
|
||||
manualAuth: NonNullable<SetupInferenceTestPlan["manualAuth"]>,
|
||||
): OpenClawConfig {
|
||||
let enabledConfig = config;
|
||||
if (manualAuth.pluginId) {
|
||||
const enableResult = enablePluginInConfig(config, manualAuth.pluginId);
|
||||
if (!enableResult.enabled) {
|
||||
throw new Error(`Provider plugin ${manualAuth.pluginId} is ${enableResult.reason}.`);
|
||||
}
|
||||
enabledConfig = enableResult.config;
|
||||
}
|
||||
const upserted = await upsertAuthProfileWithLock({
|
||||
profileId: params.profileId,
|
||||
credential: { type: "api_key", provider: params.provider, key: params.apiKey },
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (upserted === null) {
|
||||
return null;
|
||||
}
|
||||
return { profileId: params.profileId, ...(prior ? { prior } : {}) };
|
||||
return applyMergePatch(enabledConfig, manualAuth.configPatch) as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function rollbackManualApiKeyProfile(params: {
|
||||
profileId: string;
|
||||
prior?: AuthProfileCredential;
|
||||
agentDir: string;
|
||||
}): Promise<void> {
|
||||
await updateAuthProfileStoreWithLock({
|
||||
agentDir: params.agentDir,
|
||||
async function persistManualAuthProfiles(
|
||||
profiles: ProviderAuthResult["profiles"],
|
||||
agentDir: string,
|
||||
): Promise<boolean> {
|
||||
const updated = await updateAuthProfileStoreWithLock({
|
||||
agentDir,
|
||||
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
|
||||
updater: (store) => {
|
||||
if (params.prior) {
|
||||
store.profiles[params.profileId] = params.prior;
|
||||
} else {
|
||||
delete store.profiles[params.profileId];
|
||||
for (const profile of profiles) {
|
||||
store.profiles[profile.profileId] = normalizeAuthProfileCredential(profile.credential);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return updated !== null;
|
||||
}
|
||||
|
||||
async function runSetupInferenceTest(params: {
|
||||
|
|
@ -462,6 +709,7 @@ async function runSetupInferenceTest(params: {
|
|||
trigger: "manual",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
|
||||
config: plan.config,
|
||||
prompt: SETUP_INFERENCE_TEST_PROMPT,
|
||||
provider: plan.provider,
|
||||
|
|
@ -482,6 +730,7 @@ async function runSetupInferenceTest(params: {
|
|||
trigger: "manual",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
|
||||
config: plan.config,
|
||||
prompt: SETUP_INFERENCE_TEST_PROMPT,
|
||||
provider: plan.provider,
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export const crestodianHandlers: GatewayRequestHandlers = {
|
|||
};
|
||||
const result = await activateSetupInference({
|
||||
kind: params.kind,
|
||||
...(params.provider !== undefined ? { provider: params.provider } : {}),
|
||||
...(params.authChoice !== undefined ? { authChoice: params.authChoice } : {}),
|
||||
...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}),
|
||||
...(params.workspace !== undefined ? { workspace: params.workspace } : {}),
|
||||
surface: "gateway",
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ describe("plugin contract registry", () => {
|
|||
{
|
||||
provider: "github-copilot",
|
||||
method: "device",
|
||||
appGuidedSecret: true,
|
||||
choiceId: "github-copilot",
|
||||
choiceLabel: "GitHub Copilot",
|
||||
choiceHint: "Device login with your GitHub account",
|
||||
|
|
|
|||
|
|
@ -877,6 +877,7 @@ describe("loadPluginManifestRegistry", () => {
|
|||
choiceLabel: "OpenAI API key",
|
||||
assistantPriority: 10,
|
||||
assistantVisibility: "visible",
|
||||
appGuidedSecret: true,
|
||||
},
|
||||
],
|
||||
configSchema: { type: "object" },
|
||||
|
|
@ -944,6 +945,7 @@ describe("loadPluginManifestRegistry", () => {
|
|||
choiceLabel: "OpenAI API key",
|
||||
assistantPriority: 10,
|
||||
assistantVisibility: "visible",
|
||||
appGuidedSecret: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -525,6 +525,8 @@ export type PluginManifestProviderAuthChoice = {
|
|||
cliFlag?: string;
|
||||
cliOption?: string;
|
||||
cliDescription?: string;
|
||||
/** One pasted secret plus provider defaults is sufficient for app-guided setup. */
|
||||
appGuidedSecret?: boolean;
|
||||
/**
|
||||
* Interactive onboarding surfaces where this auth choice should appear.
|
||||
* Defaults to `["text-inference"]` when omitted.
|
||||
|
|
@ -1536,6 +1538,7 @@ function normalizeProviderAuthChoices(
|
|||
const cliFlag = normalizeOptionalString(entry.cliFlag) ?? "";
|
||||
const cliOption = normalizeOptionalString(entry.cliOption) ?? "";
|
||||
const cliDescription = normalizeOptionalString(entry.cliDescription) ?? "";
|
||||
const appGuidedSecret = entry.appGuidedSecret === true;
|
||||
const onboardingScopes = normalizeTrimmedStringList(entry.onboardingScopes).filter(
|
||||
(scope): scope is PluginManifestOnboardingScope =>
|
||||
scope === "text-inference" || scope === "image-generation" || scope === "music-generation",
|
||||
|
|
@ -1557,6 +1560,7 @@ function normalizeProviderAuthChoices(
|
|||
...(cliFlag ? { cliFlag } : {}),
|
||||
...(cliOption ? { cliOption } : {}),
|
||||
...(cliDescription ? { cliDescription } : {}),
|
||||
...(appGuidedSecret ? { appGuidedSecret: true } : {}),
|
||||
...(onboardingScopes.length > 0 ? { onboardingScopes } : {}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export function createProviderApiKeyAuthMethod(
|
|||
label: params.label,
|
||||
hint: params.hint,
|
||||
kind: "api_key",
|
||||
starterModel: params.defaultModel,
|
||||
wizard: params.wizard,
|
||||
run: async (ctx) => {
|
||||
const opts = ctx.opts as Record<string, unknown> | undefined;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,12 @@ import {
|
|||
import { applyAuthProfileConfig } from "./provider-auth-helpers.js";
|
||||
import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js";
|
||||
import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
|
||||
import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js";
|
||||
import type {
|
||||
ProviderAuthMethod,
|
||||
ProviderAuthOptionBag,
|
||||
ProviderAuthResult,
|
||||
ProviderPlugin,
|
||||
} from "./types.js";
|
||||
|
||||
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
|
||||
|
||||
|
|
@ -255,6 +260,67 @@ export const testing = {
|
|||
},
|
||||
} as const;
|
||||
|
||||
export async function runProviderPluginAuthMethodUnpersisted(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: WizardPrompter;
|
||||
method: ProviderAuthMethod;
|
||||
agentDir: string;
|
||||
workspaceDir: string;
|
||||
secretInputMode?: ProviderAuthOptionBag["secretInputMode"];
|
||||
allowSecretRefPrompt?: boolean;
|
||||
opts?: Partial<ProviderAuthOptionBag>;
|
||||
}): Promise<ProviderAuthResult> {
|
||||
return await params.method.run({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
opts: params.opts,
|
||||
secretInputMode: params.secretInputMode,
|
||||
allowSecretRefPrompt: params.allowSecretRefPrompt,
|
||||
isRemote: isRemoteEnvironment(),
|
||||
openUrl: async (url) => {
|
||||
await openUrl(url);
|
||||
},
|
||||
oauth: {
|
||||
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function applyProviderPluginAuthMethodResultConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
result: ProviderAuthResult;
|
||||
}): OpenClawConfig {
|
||||
const { result } = params;
|
||||
let nextConfig = params.config;
|
||||
|
||||
if (result.configPatch) {
|
||||
nextConfig = applyProviderAuthConfigPatch(nextConfig, result.configPatch, {
|
||||
replaceDefaultModels: result.replaceDefaultModels,
|
||||
});
|
||||
}
|
||||
|
||||
for (const profile of result.profiles) {
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: profile.profileId,
|
||||
provider: profile.credential.provider,
|
||||
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
|
||||
...("email" in profile.credential && profile.credential.email
|
||||
? { email: profile.credential.email }
|
||||
: {}),
|
||||
...("displayName" in profile.credential && profile.credential.displayName
|
||||
? { displayName: profile.credential.displayName }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
export async function runProviderPluginAuthMethod(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
|
@ -275,53 +341,32 @@ export async function runProviderPluginAuthMethod(params: {
|
|||
params.workspaceDir ??
|
||||
resolveAgentWorkspaceDir(params.config, agentId) ??
|
||||
resolveDefaultAgentWorkspaceDir();
|
||||
|
||||
const result = await params.method.run({
|
||||
const result = await runProviderPluginAuthMethodUnpersisted({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
runtime: params.runtime,
|
||||
prompter: params.prompter,
|
||||
method: params.method,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
opts: params.opts,
|
||||
secretInputMode: params.secretInputMode,
|
||||
allowSecretRefPrompt: params.allowSecretRefPrompt,
|
||||
isRemote: isRemoteEnvironment(),
|
||||
openUrl: async (url) => {
|
||||
await openUrl(url);
|
||||
},
|
||||
oauth: {
|
||||
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
|
||||
},
|
||||
opts: params.opts,
|
||||
});
|
||||
|
||||
let nextConfig = params.config;
|
||||
if (result.configPatch) {
|
||||
nextConfig = applyProviderAuthConfigPatch(nextConfig, result.configPatch, {
|
||||
replaceDefaultModels: result.replaceDefaultModels,
|
||||
});
|
||||
}
|
||||
|
||||
for (const profile of result.profiles) {
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
profileId: profile.profileId,
|
||||
credential: profile.credential,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: profile.profileId,
|
||||
provider: profile.credential.provider,
|
||||
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
|
||||
...("email" in profile.credential && profile.credential.email
|
||||
? { email: profile.credential.email }
|
||||
: {}),
|
||||
...("displayName" in profile.credential && profile.credential.displayName
|
||||
? { displayName: profile.credential.displayName }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
const nextConfig = applyProviderPluginAuthMethodResultConfig({
|
||||
config: params.config,
|
||||
result,
|
||||
});
|
||||
|
||||
if (params.emitNotes !== false && result.notes && result.notes.length > 0) {
|
||||
await params.prompter.note(result.notes.join("\n"), "Provider notes");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ describe("provider auth choice manifest helpers", () => {
|
|||
optionKey: "openaiApiKey",
|
||||
cliFlag: "--openai-api-key",
|
||||
cliOption: "--openai-api-key <key>",
|
||||
appGuidedSecret: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -339,6 +340,15 @@ describe("provider auth choice manifest helpers", () => {
|
|||
cliFlag: "--openai-api-key",
|
||||
cliOption: "--openai-api-key <key>",
|
||||
},
|
||||
{
|
||||
provider: "evil-openai",
|
||||
method: "api-key",
|
||||
choiceId: "evil-openai-api-key",
|
||||
choiceLabel: "Evil OpenAI API key",
|
||||
optionKey: "evilOpenaiApiKey",
|
||||
cliFlag: "--evil-openai-api-key",
|
||||
cliOption: "--evil-openai-api-key <key>",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
@ -357,6 +367,7 @@ describe("provider auth choice manifest helpers", () => {
|
|||
optionKey: "openaiApiKey",
|
||||
cliFlag: "--openai-api-key",
|
||||
cliOption: "--openai-api-key <key>",
|
||||
appGuidedSecret: true,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
|
|
@ -364,6 +375,22 @@ describe("provider auth choice manifest helpers", () => {
|
|||
includeUntrustedWorkspacePlugins: false,
|
||||
})?.providerId,
|
||||
).toBe("openai");
|
||||
const enabledWorkspaceConfig = {
|
||||
plugins: { entries: { "evil-openai-hijack": { enabled: true } } },
|
||||
};
|
||||
expect(
|
||||
resolveManifestProviderAuthChoices({
|
||||
config: enabledWorkspaceConfig,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
}).map((choice) => choice.choiceId),
|
||||
).toContain("evil-openai-api-key");
|
||||
expect(
|
||||
resolveManifestProviderAuthChoices({
|
||||
config: enabledWorkspaceConfig,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
includeWorkspacePlugins: false,
|
||||
}).map((choice) => choice.choiceId),
|
||||
).not.toContain("evil-openai-api-key");
|
||||
expect(
|
||||
resolveManifestProviderOnboardAuthFlags({
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export type ProviderAuthChoiceMetadata = {
|
|||
cliFlag?: string;
|
||||
cliOption?: string;
|
||||
cliDescription?: string;
|
||||
appGuidedSecret?: boolean;
|
||||
onboardingScopes?: ("text-inference" | "image-generation" | "music-generation")[];
|
||||
};
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ type ManifestProviderAuthChoiceParams = {
|
|||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
includeUntrustedWorkspacePlugins?: boolean;
|
||||
includeWorkspacePlugins?: boolean;
|
||||
};
|
||||
|
||||
const PROVIDER_AUTH_CHOICE_ORIGIN_PRIORITY: Readonly<Record<PluginOrigin, number>> = {
|
||||
|
|
@ -105,6 +107,7 @@ function toProviderAuthChoiceCandidate(params: {
|
|||
...(choice.cliFlag ? { cliFlag: choice.cliFlag } : {}),
|
||||
...(choice.cliOption ? { cliOption: choice.cliOption } : {}),
|
||||
...(choice.cliDescription ? { cliDescription: choice.cliDescription } : {}),
|
||||
...(choice.appGuidedSecret ? { appGuidedSecret: true } : {}),
|
||||
...(choice.onboardingScopes ? { onboardingScopes: choice.onboardingScopes } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -186,6 +189,7 @@ function resolveManifestProviderAuthChoiceCandidates(params?: {
|
|||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
includeUntrustedWorkspacePlugins?: boolean;
|
||||
includeWorkspacePlugins?: boolean;
|
||||
}): ProviderAuthChoiceCandidate[] {
|
||||
const metadataSnapshot = loadManifestMetadataSnapshot({
|
||||
config: params?.config ?? {},
|
||||
|
|
@ -195,6 +199,9 @@ function resolveManifestProviderAuthChoiceCandidates(params?: {
|
|||
const registry = metadataSnapshot.manifestRegistry;
|
||||
const normalizedConfig = normalizePluginsConfig(params?.config?.plugins);
|
||||
return registry.plugins.flatMap((plugin) => {
|
||||
if (plugin.origin === "workspace" && params?.includeWorkspacePlugins === false) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
plugin.origin === "workspace" &&
|
||||
params?.includeUntrustedWorkspacePlugins === false &&
|
||||
|
|
|
|||
|
|
@ -415,6 +415,8 @@ export type ProviderAuthMethod = {
|
|||
label: string;
|
||||
hint?: string;
|
||||
kind: ProviderAuthKind;
|
||||
/** Provider-owned model used to validate app-guided secret setup. */
|
||||
starterModel?: string;
|
||||
/**
|
||||
* Optional wizard/onboarding metadata for this specific auth method.
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue