From 0230229b42074366159c28876c78bff0de7239b9 Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Fri, 11 Sep 2026 18:10:16 +0000 Subject: [PATCH] Dynamically load served agents from GET /v1/models - AppConfig now fetches the model list from the configured host's /v1/models when the API key is set, populating availableModels from the server response (so agents added via the web admin appear without re-shipping the app). - Default host -> openai.bueso.eu, basePath /v1. - Falls back to the built-in model list if the fetch fails or no key. - Bump to 1.1. --- SwiftChat.xcodeproj/project.pbxproj | 24 +++++----- SwiftChat/Config/AppConfig.swift | 72 +++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/SwiftChat.xcodeproj/project.pbxproj b/SwiftChat.xcodeproj/project.pbxproj index 5719a83..eea6377 100644 --- a/SwiftChat.xcodeproj/project.pbxproj +++ b/SwiftChat.xcodeproj/project.pbxproj @@ -413,7 +413,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -435,7 +435,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChat; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -461,7 +461,7 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; ASSETCATALOG_OTHER_FLAGS = "--enable-icon-stack-fallback-generation=disabled"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -483,7 +483,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChat; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -505,12 +505,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChatTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -531,12 +531,12 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChatTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -556,12 +556,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChatUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -581,12 +581,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.3s.SwiftChatUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; diff --git a/SwiftChat/Config/AppConfig.swift b/SwiftChat/Config/AppConfig.swift index 2b6271f..ca26db9 100644 --- a/SwiftChat/Config/AppConfig.swift +++ b/SwiftChat/Config/AppConfig.swift @@ -32,10 +32,17 @@ class AppConfig: ObservableObject { // MARK: - Configuration /// Set your API key here or via environment - var apiKey: String = "YOUR_API_KEY" + var apiKey: String = "YOUR_API_KEY" { + didSet { + // When the key is (re)set, refresh the served model list. + if apiKey.isEmpty == false && apiKey != "YOUR_API_KEY" { + refreshModels() + } + } + } /// OpenAI-compatible API host (no scheme, no path) - var apiHost: String = "api.openai.com" + var apiHost: String = "openai.bueso.eu" /// Base path for the API var apiBasePath: String = "/v1" @@ -77,6 +84,63 @@ class AppConfig: ObservableObject { ] } + /// Fetch the served model list from `GET {host}{basePath}/models` and + /// populate `availableModels` from the response. Falls back to the default + /// list (or keeps the current list) if the request fails or the key isn't + /// set yet. Models are added to the picker exactly as served by the server. + func refreshModels() { + let key = apiKey + // Don't fire for the placeholder or an empty key. + guard !key.isEmpty, key != "YOUR_API_KEY" else { return } + + // URL: https:///models (host has no scheme) + let scheme = apiHost.contains("://") ? "" : "https://" + guard let url = URL(string: "\(scheme)\(apiHost)\(apiBasePath)/models") else { + return + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 15 + + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + guard let self else { return } + guard error == nil, + let data, + let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { + // Fallback: keep defaults if the fetch failed. + return + } + + let models: [ModelType] = items.compactMap { item in + guard let id = item["id"] as? String, !id.isEmpty else { return nil } + return ModelType( + id: id, + displayName: id, + fullName: id, + iconName: "openai-icon", + isMultimodal: false + ) + } + + guard !models.isEmpty else { return } + + Task { @MainActor in + self.availableModels = models + // Keep the selected model if it's still valid, else pick first. + if let savedId = UserDefaults.standard.string(forKey: "selectedModel"), + models.contains(where: { $0.id == savedId }) { + self.currentModel = models.first(where: { $0.id == savedId }) + } else if self.currentModel == nil || !models.contains(where: { $0.id == self.currentModel?.id }) { + self.currentModel = models.first + } + } + }.resume() + } + private func loadLastSelectedModel() { if let savedId = UserDefaults.standard.string(forKey: "selectedModel"), let model = availableModels.first(where: { $0.id == savedId }) { @@ -92,7 +156,9 @@ class AppConfig: ObservableObject { /// The model used for generating chat titles (nil = skip title generation) var titleModel: ModelType? { - availableModels.first(where: { $0.id == "gpt-4.1-mini" }) ?? availableModels.first + availableModels.first(where: { $0.id == "gpt-4.1-mini" }) + ?? availableModels.first(where: { $0.isMultimodal == false }) + ?? availableModels.first } // MARK: - OpenAI Client