diff --git a/Demo/App/APIKeyModalView.swift b/Demo/App/APIKeyModalView.swift index 2c2620c2..c4af62b9 100644 --- a/Demo/App/APIKeyModalView.swift +++ b/Demo/App/APIKeyModalView.swift @@ -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, + providerRaw: Binding, + host: Binding, + basePath: Binding, 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 } @@ -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 ) } diff --git a/Demo/App/APIProvidedView.swift b/Demo/App/APIProvidedView.swift index 0534ab46..1f60b352 100644 --- a/Demo/App/APIProvidedView.swift +++ b/Demo/App/APIProvidedView.swift @@ -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 @@ -26,13 +29,23 @@ struct APIProvidedView: View { init( apiKey: Binding, + providerRaw: Binding, + host: Binding, + basePath: Binding, githubToken: Binding, 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, @@ -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 ) @@ -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) } } diff --git a/Demo/App/APIProvider.swift b/Demo/App/APIProvider.swift new file mode 100644 index 00000000..d972092d --- /dev/null +++ b/Demo/App/APIProvider.swift @@ -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 + } + } +} diff --git a/Demo/App/DemoApp.swift b/Demo/App/DemoApp.swift index e09448a8..52774ace 100644 --- a/Demo/App/DemoApp.swift +++ b/Demo/App/DemoApp.swift @@ -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 @@ -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 } diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index baf8dacf..16b1b649 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ EFBC536829E0047400334182 /* APIKeyModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFBC536729E0047400334182 /* APIKeyModalView.swift */; }; EFBC536C29E0105800334182 /* SwiftUIAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFBC536B29E0105800334182 /* SwiftUIAdditions.swift */; }; EFE6B73329E0D47500884A87 /* APIProvidedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFE6B73229E0D47500884A87 /* APIProvidedView.swift */; }; + A12B308126480001AAAA0001 /* APIProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B308026480001AAAA0001 /* APIProvider.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -29,6 +30,7 @@ EFBC536729E0047400334182 /* APIKeyModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeyModalView.swift; sourceTree = ""; }; EFBC536B29E0105800334182 /* SwiftUIAdditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIAdditions.swift; sourceTree = ""; }; EFE6B73229E0D47500884A87 /* APIProvidedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIProvidedView.swift; sourceTree = ""; }; + A12B308026480001AAAA0001 /* APIProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -68,6 +70,7 @@ EFBC533F29DFB4EA00334182 /* DemoApp.swift */, EFBC536729E0047400334182 /* APIKeyModalView.swift */, EFE6B73229E0D47500884A87 /* APIProvidedView.swift */, + A12B308026480001AAAA0001 /* APIProvider.swift */, EFBC536529DFFF3200334182 /* ContentView.swift */, EFBC534329DFB4EB00334182 /* Assets.xcassets */, EFBC534529DFB4EB00334182 /* Demo.entitlements */, @@ -178,6 +181,7 @@ EFBC534029DFB4EA00334182 /* DemoApp.swift in Sources */, EFE6B73329E0D47500884A87 /* APIProvidedView.swift in Sources */, EFBC536829E0047400334182 /* APIKeyModalView.swift in Sources */, + A12B308126480001AAAA0001 /* APIProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };