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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 107 additions & 44 deletions Demo/App/APIKeyModalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,35 @@ struct APIKeyModalView: View {
let isMandatory: Bool

@Binding private var apiKey: String
@Binding private var providerRaw: String
@Binding private var host: String
@Binding private var basePath: String

@State private var internalAPIKey: String
@State private var internalProvider: APIProvider
@State private var internalHost: String
@State private var internalBasePath: String

public init(
apiKey: Binding<String>,
providerRaw: Binding<String>,
host: Binding<String>,
basePath: Binding<String>,
isMandatory: Bool = true
) {
self._apiKey = apiKey
self._providerRaw = providerRaw
self._host = host
self._basePath = basePath
self._internalAPIKey = State(initialValue: apiKey.wrappedValue)
let resolvedProvider = APIProvider(rawValue: providerRaw.wrappedValue) ?? .openAI
self._internalProvider = State(initialValue: resolvedProvider)
self._internalHost = State(initialValue: host.wrappedValue.isEmpty
? (resolvedProvider.defaultHost ?? "")
: host.wrappedValue)
self._internalBasePath = State(initialValue: basePath.wrappedValue.isEmpty
? (resolvedProvider.defaultBasePath ?? "")
: basePath.wrappedValue)
self.isMandatory = isMandatory
}

Expand All @@ -35,85 +56,127 @@ struct APIKeyModalView: View {
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 16) {

VStack(alignment: .leading, spacing: 8) {
Text(
"You can find and configure your OpenAI API key at"
)
.font(.caption)

Link(
"https://platform.openai.com/account/api-keys",
destination: URL(string: "https://platform.openai.com/account/api-keys")!
)
.font(.caption)
}

TextEditor(
text: $internalAPIKey
)
.frame(height: 120)
.font(.caption)
.padding(8)
.background(
RoundedRectangle(
cornerRadius: 8
)
.stroke(
strokeColor,
lineWidth: 1
)
)
.padding(4)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
providerPicker
hostFields
apiKeyField

if isMandatory {
HStack {
Spacer()

Button {
apiKey = internalAPIKey
dismiss()
commitAndDismiss()
} label: {
Text(
"Continue"
)
.padding(8)
Text("Continue").padding(8)
}
.buttonStyle(.borderedProminent)
.disabled(internalAPIKey.isEmpty)

.disabled(internalAPIKey.isEmpty || internalHost.isEmpty)
Spacer()
}
}

Spacer()
}
.padding()
.navigationTitle("OpenAI API Key")
.navigationTitle("Provider & API Key")
.toolbar {
ToolbarItem(placement: .primaryAction) {
if isMandatory {
EmptyView()
} else {
Button("Close") {
apiKey = internalAPIKey
dismiss()
commitAndDismiss()
}
}
}
}
}
}

private var providerPicker: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Provider").font(.caption).foregroundColor(.secondary)
Picker("Provider", selection: $internalProvider) {
ForEach(APIProvider.allCases) { provider in
Text(provider.displayName).tag(provider)
}
}
.pickerStyle(.menu)
.onChange(of: internalProvider) { _, newValue in
if let host = newValue.defaultHost { internalHost = host }
if let basePath = newValue.defaultBasePath { internalBasePath = basePath }
}
}
}

private var hostFields: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Host").font(.caption).foregroundColor(.secondary)
TextField("api.openai.com", text: $internalHost)
.textFieldStyle(.roundedBorder)
.disabled(internalProvider != .custom)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif

Text("Base path").font(.caption).foregroundColor(.secondary)
TextField("/v1", text: $internalBasePath)
.textFieldStyle(.roundedBorder)
.disabled(internalProvider != .custom)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
}
}

private var apiKeyField: some View {
VStack(alignment: .leading, spacing: 8) {
Text("API Key").font(.caption).foregroundColor(.secondary)
if internalProvider == .openAI {
Link(
"Get a key at platform.openai.com/account/api-keys",
destination: URL(string: "https://platform.openai.com/account/api-keys")!
)
.font(.caption)
}
TextEditor(text: $internalAPIKey)
.frame(height: 100)
.font(.caption)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(strokeColor, lineWidth: 1)
)
.padding(4)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}

private func commitAndDismiss() {
apiKey = internalAPIKey
providerRaw = internalProvider.rawValue
host = internalHost
basePath = internalBasePath
dismiss()
}
}

struct APIKeyModalView_Previews: PreviewProvider {
struct APIKeyModalView_PreviewsContainerView: View {
@State var apiKey = ""
@State var providerRaw = APIProvider.openAI.rawValue
@State var host = APIProvider.openAI.defaultHost ?? ""
@State var basePath = APIProvider.openAI.defaultBasePath ?? ""
let isMandatory: Bool

var body: some View {
APIKeyModalView(
apiKey: $apiKey,
providerRaw: $providerRaw,
host: $host,
basePath: $basePath,
isMandatory: isMandatory
)
}
Expand Down
59 changes: 45 additions & 14 deletions Demo/App/APIProvidedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import SwiftUI

struct APIProvidedView: View {
@Binding var apiKey: String
@Binding var providerRaw: String
@Binding var host: String
@Binding var basePath: String
@Binding var githubToken: String
@StateObject var chatStore: ChatStore
@StateObject var imageStore: ImageStore
Expand All @@ -26,13 +29,23 @@ struct APIProvidedView: View {

init(
apiKey: Binding<String>,
providerRaw: Binding<String>,
host: Binding<String>,
basePath: Binding<String>,
githubToken: Binding<String>,
idProvider: @escaping () -> String
) {
self._apiKey = apiKey
self._providerRaw = providerRaw
self._host = host
self._basePath = basePath
self._githubToken = githubToken

let client = APIProvidedView.makeClient(apiKey: apiKey.wrappedValue)

let client = APIProvidedView.makeClient(
apiKey: apiKey.wrappedValue,
host: host.wrappedValue,
basePath: basePath.wrappedValue
)
self._chatStore = StateObject(
wrappedValue: ChatStore(
openAIClient: client,
Expand All @@ -58,7 +71,11 @@ struct APIProvidedView: View {
self._responsesStore = StateObject(
wrappedValue: ResponsesStore(
client: OpenAI(
configuration: .init(token: apiKey.wrappedValue),
configuration: APIProvidedView.makeConfiguration(
apiKey: apiKey.wrappedValue,
host: host.wrappedValue,
basePath: basePath.wrappedValue
),
middlewares: [LoggingMiddleware()]
).responses
)
Expand All @@ -81,17 +98,31 @@ struct APIProvidedView: View {
// Connect MCP tools store to responses store
responsesStore.mcpToolsStore = mcpToolsStore
}
.onChange(of: apiKey) { _, newApiKey in
let client = APIProvidedView.makeClient(apiKey: newApiKey)
chatStore.openAIClient = client
imageStore.openAIClient = client
assistantStore.openAIClient = client
miscStore.openAIClient = client
responsesStore.client = client.responses
}
.onChange(of: apiKey) { _, _ in rewireClient() }
.onChange(of: host) { _, _ in rewireClient() }
.onChange(of: basePath) { _, _ in rewireClient() }
}

private static func makeClient(apiKey: String) -> OpenAIProtocol {
OpenAI(apiToken: apiKey)

private func rewireClient() {
let client = APIProvidedView.makeClient(
apiKey: apiKey,
host: host,
basePath: basePath
)
chatStore.openAIClient = client
imageStore.openAIClient = client
assistantStore.openAIClient = client
miscStore.openAIClient = client
responsesStore.client = client.responses
}

private static func makeClient(apiKey: String, host: String, basePath: String) -> OpenAIProtocol {
OpenAI(configuration: makeConfiguration(apiKey: apiKey, host: host, basePath: basePath))
}

private static func makeConfiguration(apiKey: String, host: String, basePath: String) -> OpenAI.Configuration {
let resolvedHost = host.isEmpty ? "api.openai.com" : host
let resolvedBasePath = basePath.isEmpty ? "/v1" : basePath
return OpenAI.Configuration(token: apiKey, host: resolvedHost, basePath: resolvedBasePath)
}
Comment on lines +123 to 127
}
50 changes: 50 additions & 0 deletions Demo/App/APIProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// APIProvider.swift
// Demo
//
// Predefined provider configurations for the OpenAI-compatible Demo client.
//

import Foundation

enum APIProvider: String, CaseIterable, Identifiable {
case openAI
case gemini
case groq
case openRouter
case custom

var id: String { rawValue }

var displayName: String {
switch self {
case .openAI: return "OpenAI"
case .gemini: return "Gemini (OpenAI-compatible)"
case .groq: return "Groq"
case .openRouter: return "OpenRouter"
case .custom: return "Custom…"
}
}

/// Default host for the provider. `nil` for `.custom` (user fills in).
var defaultHost: String? {
switch self {
case .openAI: return "api.openai.com"
case .gemini: return "generativelanguage.googleapis.com"
case .groq: return "api.groq.com"
case .openRouter: return "openrouter.ai"
case .custom: return nil
}
}

/// Default basePath for the provider (matches the OpenAI SDK's path style).
var defaultBasePath: String? {
switch self {
case .openAI: return "/v1"
case .gemini: return "/v1beta/openai"
case .groq: return "/openai/v1"
case .openRouter: return "/api/v1"
case .custom: return nil
}
}
}
20 changes: 18 additions & 2 deletions Demo/App/DemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import SwiftUI
@main
struct DemoApp: App {
@AppStorage("apiKey") var apiKey: String = ""
@AppStorage("apiProvider") var providerRaw: String = APIProvider.openAI.rawValue
@AppStorage("apiHost") var host: String = APIProvider.openAI.defaultHost ?? "api.openai.com"
@AppStorage("apiBasePath") var basePath: String = APIProvider.openAI.defaultBasePath ?? "/v1"
@AppStorage("githubToken") var githubToken: String = ""
@State var isShowingAPIConfigModal: Bool = true

Expand All @@ -30,17 +33,30 @@ struct DemoApp: App {
Group {
APIProvidedView(
apiKey: $apiKey,
providerRaw: $providerRaw,
host: $host,
basePath: $basePath,
githubToken: $githubToken,
idProvider: idProvider
)
}
#if os(iOS)
.fullScreenCover(isPresented: $isShowingAPIConfigModal) {
APIKeyModalView(apiKey: $apiKey)
APIKeyModalView(
apiKey: $apiKey,
providerRaw: $providerRaw,
host: $host,
basePath: $basePath
)
}
#elseif os(macOS)
.popover(isPresented: $isShowingAPIConfigModal) {
APIKeyModalView(apiKey: $apiKey)
APIKeyModalView(
apiKey: $apiKey,
providerRaw: $providerRaw,
host: $host,
basePath: $basePath
)
}
#endif
}
Expand Down
Loading