From 4ea707083fc5af13d73e457720dffb82b8dac1be Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 17 Sep 2026 08:52:16 +0800 Subject: [PATCH] Support keyless model providers and HTTP endpoints Allow optional API keys in desktop and mobile settings and share credential resolution across model requests. Missing stored credentials remain errors. Enable user-configured HTTP endpoints and pin signed AgentKit 0.13.1, which omits empty authentication headers for probes and inference. Validation: AgentKit's 216 tests and iOS's 461 tests passed, along with Swift formatting, localization checks, native screenshots, and real HTTP smoke tests on both apps. macOS passed 1419 of 1422 tests initially; existing Metrics timing, waveform PNG comparison, and MCP upload runner failures were observed. MCP and Metrics passed a focused rerun, while Metrics remains intermittent and waveform comparison still fails. iOS simulator LAN-IP access timed out; desktop LAN-IP and both platforms' localhost, loopback-IP, and local-domain requests passed. --- .../ModelProviderCredentialTests.swift | 80 +++++++++++++++++++ Frameworks/Resources/InfoPlist.xcstrings | 23 ++++++ Frameworks/Resources/Localizable.xcstrings | 23 ++++++ .../Agent/AssistantExecutionController.swift | 9 +-- .../Agent/CommandGeneratorService.swift | 4 +- .../Agent/ConversationTitleController.swift | 2 +- .../FeatureModels/AIConfigurationModel.swift | 12 ++- Memory/ARCHITECTURE.md | 16 ++++ .../FeatureModels/MobileModelDiscovery.swift | 6 +- .../MobileProviderEditorModel.swift | 15 ++-- .../FeatureModels/MobileSecretDraft.swift | 1 + .../Settings/MobileAISettingsView.swift | 3 +- TFI/Info.plist | 7 ++ TFITests/MobileAISettingsRenderingTests.swift | 9 +++ TFITests/MobileAISettingsTests.swift | 59 +++++++++++++- TFITests/MobileSettingsPortTests.swift | 1 + TFITests/ProviderHTTPIntegrationTests.swift | 32 ++++++++ TFM/Features/Settings/ModelListEditor.swift | 26 +++--- .../Settings/ModelProviderEditorSheet.swift | 2 +- .../Settings/ModelProviderSettings.swift | 10 +-- TFM/Info.plist | 7 ++ TFMTests/ModelProviderRenderingTests.swift | 51 ++++++++++++ .../ProviderEndpointIntegrationTests.swift | 17 ++++ TFMTests/ProviderHTTPIntegrationTests.swift | 32 ++++++++ Termind.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +- 26 files changed, 411 insertions(+), 44 deletions(-) create mode 100644 FrameworkTests/ModelProviderCredentialTests.swift create mode 100644 TFITests/ProviderHTTPIntegrationTests.swift create mode 100644 TFMTests/ModelProviderRenderingTests.swift create mode 100644 TFMTests/ProviderHTTPIntegrationTests.swift diff --git a/FrameworkTests/ModelProviderCredentialTests.swift b/FrameworkTests/ModelProviderCredentialTests.swift new file mode 100644 index 00000000..dd570b7e --- /dev/null +++ b/FrameworkTests/ModelProviderCredentialTests.swift @@ -0,0 +1,80 @@ +import AgentKit +import Foundation +import TermindApplication +import Testing + +@Suite @MainActor +struct ModelProviderCredentialTests { + @Test func anonymousProvidersIgnoreUnrelatedSecretFailuresWithoutReadingStorage() { + let secrets = ProviderTestSecrets() + secrets.failReads = true + let ai = AIConfigurationModel(aiSecrets: secrets) + #expect(ai.secret(for: "unavailable") == nil) + #expect(ai.secretStorageError != nil) + let provider = ModelProvider(name: "Anonymous", models: [AIModel(id: "test")]) + ai.upsert(provider) + #expect(ai.modelSecret(for: provider) == "") + #expect(CommandGeneratorController(ai: ai).isLocallyAvailable) + #expect(secrets.reads == ["unavailable"]) + #expect(secrets.values.isEmpty) + } + + @Test func configuredReferencesNeverFallBackToAnonymousRequests() { + let secrets = ProviderTestSecrets() + let ai = AIConfigurationModel(aiSecrets: secrets) + let provider = ModelProvider(name: "Authenticated", credentialRef: "key", models: [AIModel(id: "test")]) + ai.upsert(provider) + let command = CommandGeneratorController(ai: ai) + #expect(ai.modelSecret(for: provider) == nil) + #expect(!command.isLocallyAvailable) + secrets.values["key"] = "" + #expect(ai.modelSecret(for: provider) == nil) + secrets.values["key"] = "test-key" + #expect(ai.modelSecret(for: provider) == "test-key") + #expect(command.isLocallyAvailable) + secrets.failReads = true + #expect(ai.modelSecret(for: provider) == nil) + #expect(!command.isLocallyAvailable) + } + + @Test func vertexResolvesOnlyItsServiceAccountCredential() { + let secrets = ProviderTestSecrets() + let ai = AIConfigurationModel(aiSecrets: secrets) + var provider = ModelProvider( + name: "Vertex", apiFormat: .generateContent, credentialRef: "ordinary", usesVertex: true) + secrets.values["ordinary"] = "wrong-key" + #expect(ai.modelSecret(for: provider) == nil) + provider.vertex.credentialRef = "service-account" + #expect(ai.modelSecret(for: provider) == nil) + secrets.values["service-account"] = "service-account-json" + #expect(ai.modelSecret(for: provider) == "service-account-json") + } + + @Test func clearingAKeyEnablesAnonymousResolutionWithoutCreatingASecret() { + let secrets = ProviderTestSecrets() + let ai = AIConfigurationModel(aiSecrets: secrets) + var provider = ModelProvider(name: "Gateway", credentialRef: "key") + ai.setSecret("test-key", for: "key") + #expect(ai.modelSecret(for: provider) == "test-key") + ai.setSecret(nil, for: "key") + #expect(ai.modelSecret(for: provider) == nil) + provider.credentialRef = "" + #expect(ai.modelSecret(for: provider) == "") + #expect(secrets.values.isEmpty) + } +} + +@MainActor private final class ProviderTestSecrets: AISecretStoring { + enum Failure: Error { case unavailable } + var values: [String: String] = [:] + var reads: [String] = [] + var failReads = false + + func string(for reference: String) throws -> String? { + reads.append(reference) + if failReads { throw Failure.unavailable } + return values[reference] + } + + func setString(_ value: String?, for reference: String) throws { values[reference] = value } +} diff --git a/Frameworks/Resources/InfoPlist.xcstrings b/Frameworks/Resources/InfoPlist.xcstrings index 5c72a61e..c23a877a 100644 --- a/Frameworks/Resources/InfoPlist.xcstrings +++ b/Frameworks/Resources/InfoPlist.xcstrings @@ -1,6 +1,29 @@ { "sourceLanguage" : "en", "strings" : { + "NSLocalNetworkUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind connects to servers and model providers on your local network." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind会连接你局域网中的服务器和模型提供商。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind會連線至你區域網路中的伺服器和模型供應商。" + } + } + } + }, "CFBundleDisplayName" : { "comment" : "Bundle display name", "extractionState" : "extracted_with_value", diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 7e0f7187..d1380a97 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -24200,6 +24200,29 @@ } } }, + "The provider's credential is unavailable." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The provider's credential is unavailable." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提供商的凭据不可用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "供應商的憑證無法使用。" + } + } + } + }, "The proxy configuration is incomplete." : { "extractionState" : "stale", "localizations" : { diff --git a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift index 05d20440..632b2c9d 100644 --- a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift +++ b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift @@ -167,7 +167,7 @@ public final class AssistantExecutionController { ) return } - guard let secret = ai.secret(for: chat.provider.effectiveCredentialRef), !secret.isEmpty else { + guard let secret = ai.modelSecret(for: chat.provider) else { recordFailedRun( conversation, promptID: promptID ?? lastUserTurn?.id ?? UUID(), @@ -224,8 +224,7 @@ public final class AssistantExecutionController { .setting(AgentSSHInteraction.unavailable, for: .sshInteraction) let reviewer: (any GuardianReviewing)? = { guard mode == .approveForMe, let review = ai.resolve(.guardian) else { return nil } - guard let reviewSecret = ai.secret(for: review.provider.effectiveCredentialRef), - !reviewSecret.isEmpty + guard let reviewSecret = ai.modelSecret(for: review.provider) else { return nil } let reasoning = GuardianClient.preferredReasoning( model: review.model, provider: review.provider @@ -482,7 +481,7 @@ public final class AssistantExecutionController { /// expensive. private func compactionModel() -> (any AgentModelCompleting)? { guard let resolved = ai.resolve(.chat) else { return nil } - guard let secret = ai.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty + guard let secret = ai.modelSecret(for: resolved.provider) else { return nil } let reasoning = ModelCapabilityResolver.reasoning( model: resolved.model, provider: resolved.provider @@ -540,7 +539,7 @@ public final class AssistantExecutionController { private func memoryReviewModel(_ conversation: ConversationModel) -> (any AgentModelCompleting)? { guard memory?.isEnabled == true else { return nil } guard let resolved = ai.resolve(conversation: memory?.reviewModel ?? conversation.model), - let secret = ai.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty + let secret = ai.modelSecret(for: resolved.provider) else { return nil } return AgentProviderClient( provider: resolved.provider, model: resolved.model, secret: secret, diff --git a/Frameworks/TermindApplication/Agent/CommandGeneratorService.swift b/Frameworks/TermindApplication/Agent/CommandGeneratorService.swift index f836ab53..48950fe3 100644 --- a/Frameworks/TermindApplication/Agent/CommandGeneratorService.swift +++ b/Frameworks/TermindApplication/Agent/CommandGeneratorService.swift @@ -111,14 +111,14 @@ public final class CommandGeneratorController: CommandGenerating { /// it only resolves the configured role and its already local credential. public var isLocallyAvailable: Bool { guard let resolved = ai.resolve(.commandGenerator) else { return false } - return ai.secret(for: resolved.provider.effectiveCredentialRef)?.isEmpty == false + return ai.modelSecret(for: resolved.provider) != nil } public func generate(_ context: CommandGeneratorContext) async throws -> String { guard let resolved = ai.resolve(.commandGenerator) else { throw CommandGeneratorError.missingModel } - guard let secret = ai.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty else { + guard let secret = ai.modelSecret(for: resolved.provider) else { throw CommandGeneratorError.missingCredential } let reasoning = ModelCapabilityResolver.reasoning( diff --git a/Frameworks/TermindApplication/Agent/ConversationTitleController.swift b/Frameworks/TermindApplication/Agent/ConversationTitleController.swift index de024532..fff55a51 100644 --- a/Frameworks/TermindApplication/Agent/ConversationTitleController.swift +++ b/Frameworks/TermindApplication/Agent/ConversationTitleController.swift @@ -21,7 +21,7 @@ public final class ConversationTitleController: ConversationTitling { guard let resolved = ai.resolve(.chat) else { throw ConversationTitleError.missingModel } - guard let secret = ai.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty else { + guard let secret = ai.modelSecret(for: resolved.provider) else { throw ConversationTitleError.missingCredential } // `.off`, not `.low`. The shared clamp walks *upward* first, which is diff --git a/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift b/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift index 952c8faa..a66412ca 100644 --- a/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift @@ -6,7 +6,7 @@ import TermindDomain /// what, and the MCP servers. /// /// Its own store rather than more fields on `HostLibraryModel`: that one is about -/// machines you connect to, this one is about endpoints you spend a key at. +/// machines you connect to, this one is about model endpoints. /// Provider and MCP configuration are projected from SwiftData in production /// while previews can still construct an in-memory store. @Observable @@ -372,6 +372,16 @@ public final class AIConfigurationModel { // MARK: - Secrets + /// An empty result explicitly means no authentication. A missing result means + /// a configured credential is unavailable; callers must not send anonymously. + /// Vertex always requires a service account, even without a stored reference. + public func modelSecret(for provider: ModelProvider) -> String? { + let reference = provider.effectiveCredentialRef + if reference.isEmpty { return provider.usesVertexEndpoint ? nil : "" } + guard let value = secret(for: reference), !value.isEmpty else { return nil } + return value + } + /// The secret behind a `credentialRef`, loaded from the Termind Data /// Protection Keychain and cached only for this process. public func secret(for ref: String) -> String? { diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 39845038..fad89d1f 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -281,6 +281,22 @@ inherited task isolation; remote I/O stays behind asynchronous/detached boundari ## Agent runtime and security +Model-provider credentials are optional on macOS and mobile. An empty credential +reference means anonymous requests; a nonempty reference whose secret is missing, +empty, or unreadable remains an error. `AIConfigurationModel.modelSecret(for:)` +shares this distinction across discovery, probes, Chat, Guardian, titles, compact, +Command Generator, and Memory review. Anonymous resolution never reads secret +storage or inherits another provider's storage error. Vertex still requires its +service account. AgentKit omits empty authentication headers for ordinary providers. +No authentication mode or persistence schema is added. + +Both apps accept user-selected HTTP and HTTPS model endpoints. Their ATS arbitrary +loads exception supports unknown user-configured domains, including local servers; +HTTPS keeps default certificate trust evaluation. No local-network ATS exception +may override that setting. Local-network usage descriptions are localized. App Store +review notes must explain that users configure arbitrary self-hosted model servers, +including HTTP endpoints which the app does not own and cannot upgrade to HTTPS. + `AgentKit` owns the provider adapters, loop, approval gate, MCP client, and built-in host-agnostic tools. Termind owns everything that reaches its machines or people: SSH/SFTP tools, connection pools, Vault resolution, persistence, policy, and UI. diff --git a/TFI/Application/FeatureModels/MobileModelDiscovery.swift b/TFI/Application/FeatureModels/MobileModelDiscovery.swift index bc2d850b..3091654d 100644 --- a/TFI/Application/FeatureModels/MobileModelDiscovery.swift +++ b/TFI/Application/FeatureModels/MobileModelDiscovery.swift @@ -21,8 +21,10 @@ final class MobileModelDiscovery { func start(ai: AIConfigurationModel, provider: ModelProvider) { cancel() do { - let secret = ai.secret(for: provider.effectiveCredentialRef) ?? "" - if let error = ai.secretStorageError { throw MobileEditorError.message(error) } + guard let secret = ai.modelSecret(for: provider) else { + throw MobileEditorError.message( + ai.secretStorageError ?? String(localized: "The provider's credential is unavailable.")) + } start(provider: provider, secret: secret, isCurrent: { ai.modelProvider(provider.id) == provider }) } catch { self.error = error.localizedDescription } } diff --git a/TFI/Application/FeatureModels/MobileProviderEditorModel.swift b/TFI/Application/FeatureModels/MobileProviderEditorModel.swift index c6cd32ea..d08dba0c 100644 --- a/TFI/Application/FeatureModels/MobileProviderEditorModel.swift +++ b/TFI/Application/FeatureModels/MobileProviderEditorModel.swift @@ -31,16 +31,15 @@ final class MobileProviderEditorModel { originalEndpoint = wasPersisted ? ai.endpoint(for: provider.id) : ProviderEndpoint(url: provider.inferenceURL) endpoint = originalEndpoint key.begin(in: ai, reference: provider.credentialRef) - if requiresKey { key.action = .replace } } var hasExistingKey: Bool { wasPersisted && !original.credentialRef.isEmpty } - var requiresKey: Bool { !wasPersisted } var isDirty: Bool { draft != original || endpoint != originalEndpoint || key.isDirty } var canSave: Bool { let url = URL(string: endpoint.requestURL) return !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && url?.scheme == "https" && url?.host != nil && key.isValid + && ["http", "https"].contains(url?.scheme?.lowercased() ?? "") + && url?.host?.isEmpty == false && key.isValid } var requestProvider: ModelProvider { @@ -55,8 +54,14 @@ final class MobileProviderEditorModel { guard !wasPersisted || ai.modelProvider(original.id) != nil else { throw MobileEditorError.message(String(localized: "Provider Deleted")) } - return try key.effective( - in: ai, reference: ai.modelProvider(original.id)?.effectiveCredentialRef ?? draft.effectiveCredentialRef) + if key.action == .preserve { + guard let secret = ai.modelSecret(for: ai.modelProvider(original.id) ?? requestProvider) else { + throw MobileEditorError.message( + ai.secretStorageError ?? String(localized: "The provider's credential is unavailable.")) + } + return secret + } + return try key.effective(in: ai, reference: draft.effectiveCredentialRef) } func add(_ models: [AIModel]) { diff --git a/TFI/Application/FeatureModels/MobileSecretDraft.swift b/TFI/Application/FeatureModels/MobileSecretDraft.swift index 9d3313f5..3a86ce82 100644 --- a/TFI/Application/FeatureModels/MobileSecretDraft.swift +++ b/TFI/Application/FeatureModels/MobileSecretDraft.swift @@ -28,6 +28,7 @@ final class MobileSecretDraft { case .clear: return "" case .replace: return text case .preserve: + guard !reference.isEmpty else { return "" } let value = ai.secret(for: reference) ?? "" if let error = ai.secretStorageError { throw MobileEditorError.message(error) } return value diff --git a/TFI/Features/Settings/MobileAISettingsView.swift b/TFI/Features/Settings/MobileAISettingsView.swift index ae757fed..6defacf1 100644 --- a/TFI/Features/Settings/MobileAISettingsView.swift +++ b/TFI/Features/Settings/MobileAISettingsView.swift @@ -214,8 +214,7 @@ struct MobileProviderEditor: View { .textInputAutocapitalization(.never) MobileSecretFields( key: model.key, - hasExistingCredential: model.hasExistingKey, - requiresCredential: model.requiresKey + hasExistingCredential: model.hasExistingKey ) modelSection.buttonStyle(.borderless) if let error { Text(error).foregroundStyle(.red) } diff --git a/TFI/Info.plist b/TFI/Info.plist index ea6b0134..add2e935 100644 --- a/TFI/Info.plist +++ b/TFI/Info.plist @@ -2,6 +2,13 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSLocalNetworkUsageDescription + Termind connects to servers and model providers on your local network. CFBundleURLTypes diff --git a/TFITests/MobileAISettingsRenderingTests.swift b/TFITests/MobileAISettingsRenderingTests.swift index 9d3ac239..db017fbb 100644 --- a/TFITests/MobileAISettingsRenderingTests.swift +++ b/TFITests/MobileAISettingsRenderingTests.swift @@ -13,6 +13,15 @@ import struct TermindDomain.Host /// Uses the shipping views and a disposable in-memory app, without accounts or network requests. @Suite(.serialized) @MainActor struct MobileAISettingsRenderingTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func anonymousProviderEditorShowsOptionalKey(_ language: String) async throws { + let ai = AIConfigurationModel() + let provider = ModelProvider(name: "Local Model", inferenceURL: "http://localhost:11434/v1") + try await capture( + "anonymous-provider", language: language, + view: MobileProviderEditor(ai: ai, provider: provider).environment(\.locale, Locale(identifier: language))) + } + @Test func renderSettingsEditors() async throws { guard let language = ProcessInfo.processInfo.environment["TERMIND_AI_SCREENSHOTS"] else { return } let app = try MobileAppModel(inMemory: true) diff --git a/TFITests/MobileAISettingsTests.swift b/TFITests/MobileAISettingsTests.swift index e387d5fd..4157e790 100644 --- a/TFITests/MobileAISettingsTests.swift +++ b/TFITests/MobileAISettingsTests.swift @@ -8,6 +8,57 @@ import Testing @Suite @MainActor struct MobileAISettingsTests { + @Test(arguments: [ + "http://localhost:11434/v1", "http://192.168.1.10:8080/v1", "http://llm.example.com/v1", + "https://example.com/v1", + ]) + func newProvidersCanSaveAndDiscoverWithoutAKey(_ url: String) async throws { + let ai = AIConfigurationModel() + let editor = MobileProviderEditorModel(ai: ai, provider: ModelProvider(name: "Anonymous")) + editor.endpoint.url = url + #expect(editor.canSave) + #expect(try editor.requestSecret() == "") + let discovery = MobileModelDiscovery(fetch: { _, secret in + #expect(secret == "") + return [AIModel(id: "test")] + }) + discovery.start(provider: editor.requestProvider, secret: try editor.requestSecret()) + while discovery.running { await Task.yield() } + editor.add(discovery.candidates) + try editor.save() + let saved = try #require(ai.modelProvider(editor.original.id)) + #expect(saved.credentialRef.isEmpty) + #expect(saved.models.map(\.id) == ["test"]) + let reopened = MobileProviderEditorModel(ai: ai, provider: saved) + #expect(reopened.canSave) + #expect(try reopened.requestSecret() == "") + let probes = MobileModelTests(probe: { _, _, secret in + #expect(secret == "") + return "OK" + }) + probes.start(models: saved.models, provider: saved, secret: try reopened.requestSecret()) + while probes.running { await Task.yield() } + #expect(probes.results["test"] == "OK") + } + + @Test(arguments: ["", "file:///tmp/model", "ftp://example.com/model", "http:///", "example.com/v1"]) + func invalidProviderAddressesCannotBeSaved(_ url: String) { + let editor = MobileProviderEditorModel(ai: AIConfigurationModel(), provider: ModelProvider(name: "Invalid")) + editor.endpoint.url = url + #expect(!editor.canSave) + } + + @Test func missingStoredKeyRequiresExplicitClearBeforeAnonymousPreview() throws { + let provider = ModelProvider(name: "Missing", inferenceURL: "http://localhost/v1", credentialRef: "missing") + let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false) + let editor = MobileProviderEditorModel(ai: ai, provider: provider) + #expect(throws: (any Error).self) { try editor.requestSecret() } + editor.keyAction = .clear + #expect(try editor.requestSecret() == "") + try editor.save() + #expect(ai.modelProvider(provider.id)?.credentialRef == "") + } + private func provider() -> ModelProvider { var value = ModelProvider(name: "Gateway", inferenceURL: "https://example.invalid/v1/responses") value.models = [AIModel(id: "one"), AIModel(id: "two")] @@ -22,7 +73,6 @@ struct MobileAISettingsTests { ai.select(ref) let editor = MobileProviderEditorModel(ai: ai, provider: provider) #expect(!editor.hasExistingKey) - #expect(!editor.requiresKey) #expect(editor.keyAction == .preserve) editor.remove(["one"]) editor.add(id: "new-model") @@ -91,11 +141,12 @@ struct MobileAISettingsTests { let ai = AIConfigurationModel() let editor = MobileProviderEditorModel(ai: ai, provider: provider()) #expect(!editor.hasExistingKey) - #expect(editor.requiresKey) - #expect(editor.keyAction == .replace) - #expect(!editor.canSave) + #expect(editor.keyAction == .preserve) + #expect(editor.canSave) + #expect(try editor.requestSecret() == "") editor.endpoint.url = "https://draft.invalid/v1" editor.endpoint.apiType = .responses + editor.keyAction = .replace editor.secret = "draft-key" #expect(editor.requestProvider.inferenceURL == "https://draft.invalid/v1/responses") #expect(try editor.requestSecret() == "draft-key") diff --git a/TFITests/MobileSettingsPortTests.swift b/TFITests/MobileSettingsPortTests.swift index 1bc7424d..7beac4fb 100644 --- a/TFITests/MobileSettingsPortTests.swift +++ b/TFITests/MobileSettingsPortTests.swift @@ -62,6 +62,7 @@ struct MobileSettingsPortTests { let editor = MobileProviderEditorModel(ai: ai, provider: ModelProvider(name: "Gateway")) editor.endpoint.url = "https://example.invalid/v1" editor.endpoint.apiType = .responses + editor.keyAction = .replace editor.secret = "test-key" try editor.save() let saved = try #require(ai.modelProvider(editor.original.id)) diff --git a/TFITests/ProviderHTTPIntegrationTests.swift b/TFITests/ProviderHTTPIntegrationTests.swift new file mode 100644 index 00000000..f8cba711 --- /dev/null +++ b/TFITests/ProviderHTTPIntegrationTests.swift @@ -0,0 +1,32 @@ +import AgentKit +import Foundation +import TermindApplication +import Testing + +/// Opt-in real-network smoke test against a disposable compatible model server. +/// TERMIND_MODEL_HTTP_TEST_URLS is a comma-separated list of base URLs. The server +/// answers ordinary prompts with OK and honors the title/command JSON contracts. +@Suite @MainActor +struct ProviderHTTPIntegrationTests { + @Test func nativeAppCanUseAnonymousHTTPEndpoints() async throws { + guard let urls = ProcessInfo.processInfo.environment["TERMIND_MODEL_HTTP_TEST_URLS"] else { return } + for base in urls.split(separator: ",").map(String.init) { + let provider = ModelProvider( + name: "Anonymous HTTP", inferenceURL: base + "/chat/completions", baseURL: base, + models: [AIModel(id: "test")]) + let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false) + let secret = try #require(ai.modelSecret(for: provider)) + #expect(secret.isEmpty) + let models = try await ModelCatalogClient().models(for: provider, secret: secret) + #expect(models.map(\.id) == ["test"]) + let probe = try await ChatProbe().run(model: provider.models[0], on: provider, secret: secret) + #expect(probe.reply == "OK") + let command = CommandGeneratorController(ai: ai) + #expect(command.isLocallyAvailable) + #expect(try await command.generate(.init(draft: "say OK", shell: "zsh", cwd: "/tmp")) == "echo OK") + let title = try await ConversationTitleController(ai: ai).summarize( + .init(firstUserMessage: "Say OK", firstAssistantMessage: "OK")) + #expect(title == "Local model") + } + } +} diff --git a/TFM/Features/Settings/ModelListEditor.swift b/TFM/Features/Settings/ModelListEditor.swift index 31914e50..aa9e88cf 100644 --- a/TFM/Features/Settings/ModelListEditor.swift +++ b/TFM/Features/Settings/ModelListEditor.swift @@ -102,7 +102,6 @@ struct ModelListEditor: View { Button("Test", action: testChecked) .disabled( state.checked.isEmpty || state.isTesting - || provider?.hasCredential != true ) .help(Text("Sends each selected model a short prompt and shows the response.")) @@ -211,9 +210,6 @@ struct ModelListEditor: View { if provider.resolvedModelsURL == nil { return Text("Add a URL first. Models are listed from the endpoint's own address.") } - if !provider.hasCredential { - return Text("Add a key first. This endpoint requires one to list models.") - } return Text("Fetch available models from the endpoint.") } @@ -273,7 +269,7 @@ struct ModelListEditor: View { isSelected: state.selection == model.id, isChecked: state.checked.contains(model.id), result: state.results[model.id], - canTest: provider?.hasCredential == true && !state.isTesting, + canTest: !state.isTesting, select: { state.selection = model.id }, check: { toggleCheck(model.id) }, edit: { state.selection = model.id }, @@ -290,7 +286,7 @@ struct ModelListEditor: View { private var emptyHint: LocalizedStringKey { guard let provider else { return "" } if provider.resolvedModelsURL == nil { return "Add a URL, then press Fetch" } - return provider.hasCredential ? "Press Fetch" : "Add a key, then press Fetch" + return "Press Fetch" } // MARK: - Detail @@ -600,7 +596,12 @@ struct ModelListEditor: View { private func fetchModels() { guard let provider else { return } state.fetch = .running - let secret = app.aiConfiguration.secret(for: provider.credentialRef) ?? "" + guard let secret = app.aiConfiguration.modelSecret(for: provider) else { + state.fetch = .failed( + app.aiConfiguration.secretStorageError ?? String(localized: "The provider's credential is unavailable.") + ) + return + } Task { do { state.candidates = try await ModelCatalogClient().models( @@ -625,10 +626,17 @@ struct ModelListEditor: View { } private func testModels(_ targets: [AIModel]) { - guard let provider, provider.hasCredential, !state.isTesting, !targets.isEmpty else { + guard let provider, !state.isTesting, !targets.isEmpty else { + return + } + guard let credential = app.aiConfiguration.modelSecret(for: provider) else { + for model in targets { + state.results[model.id] = .failed( + app.aiConfiguration.secretStorageError + ?? String(localized: "The provider's credential is unavailable.")) + } return } - let credential = app.aiConfiguration.secret(for: provider.effectiveCredentialRef) ?? "" state.isTesting = true for model in targets { diff --git a/TFM/Features/Settings/ModelProviderEditorSheet.swift b/TFM/Features/Settings/ModelProviderEditorSheet.swift index 1866860c..c516d08d 100644 --- a/TFM/Features/Settings/ModelProviderEditorSheet.swift +++ b/TFM/Features/Settings/ModelProviderEditorSheet.swift @@ -117,7 +117,7 @@ struct ModelProviderEditorPage: View { text: Binding( get: { session.secret }, set: { session.secret = $0 } ), - prompt: Text("Required") + prompt: Text("Optional") ) .onChange(of: session.secret) { _, value in store(secret: value) } diff --git a/TFM/Features/Settings/ModelProviderSettings.swift b/TFM/Features/Settings/ModelProviderSettings.swift index 6b89a8a2..403a11c5 100644 --- a/TFM/Features/Settings/ModelProviderSettings.swift +++ b/TFM/Features/Settings/ModelProviderSettings.swift @@ -118,18 +118,12 @@ private struct ModelProviderRow: View { ) } - /// The two facts that decide whether this row can do anything: does it have - /// a credential, and does it know any models. - /// - /// "No Key" is orange rather than red — an unfinished provider is a normal - /// state on the way to a finished one, not an error. + /// Credentials are optional, so model availability determines the status. @ViewBuilder private var chips: some View { CardChip(text: provider.apiFormat.displayName) - if !provider.hasCredential { - CardChip(text: String(localized: "No Key"), symbol: "exclamationmark.circle", tint: .orange) - } else if provider.models.isEmpty { + if provider.models.isEmpty { CardChip(text: String(localized: "No Models"), symbol: "questionmark.circle") } else { CardChip(text: "\(provider.models.count)", symbol: "cube") diff --git a/TFM/Info.plist b/TFM/Info.plist index d924dc14..1972bfab 100644 --- a/TFM/Info.plist +++ b/TFM/Info.plist @@ -2,6 +2,13 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSLocalNetworkUsageDescription + Termind connects to servers and model providers on your local network. CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/TFMTests/ModelProviderRenderingTests.swift b/TFMTests/ModelProviderRenderingTests.swift new file mode 100644 index 00000000..abb5efd9 --- /dev/null +++ b/TFMTests/ModelProviderRenderingTests.swift @@ -0,0 +1,51 @@ +import AgentKit +import AppKit +import SwiftUI +import TermindApplication +import Testing + +@testable import Termind + +@Suite(.serialized) @MainActor +struct ModelProviderRenderingTests { + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) + func anonymousProviderSettingsAndModelsRender(_ language: String) async throws { + let provider = ModelProvider( + name: "Local Model", inferenceURL: "http://localhost:11434/v1/chat/completions", + baseURL: "http://localhost:11434/v1", models: [AIModel(id: "local-model")]) + let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false) + let app = AppModel(hosts: HostLibraryModel(), ai: ai) + defer { app.shutdownForTermination() } + try await capture( + ModelProviderEditorPage(modelProviderID: provider.id, showModels: {}, deleted: {}).environment(app), + language: language, name: "provider") + try await capture( + ModelListEditor(modelProviderID: provider.id).environment(app), language: language, name: "models") + try await capture( + Form { ModelProviderSettings(edit: { _, _ in }) }.formStyle(.grouped).environment(app), + language: language, name: "provider-list") + } + + private func capture(_ view: some View, language: String, name: String) async throws { + let content = view.environment(\.locale, Locale(identifier: language)).frame(width: 680, height: 700) + let controller = NSHostingController(rootView: content) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 680, height: 700), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentViewController = controller + window.orderFront(nil) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await Task.sleep(for: .milliseconds(600)) + controller.view.layoutSubtreeIfNeeded() + let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds)) + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + Attachment.record(data, named: "keyless-\(name)-\(language).png") + #expect(data.count > 10_000) + } +} diff --git a/TFMTests/ProviderEndpointIntegrationTests.swift b/TFMTests/ProviderEndpointIntegrationTests.swift index 0960d778..b89c85be 100644 --- a/TFMTests/ProviderEndpointIntegrationTests.swift +++ b/TFMTests/ProviderEndpointIntegrationTests.swift @@ -12,6 +12,23 @@ import Testing @Suite @MainActor struct ProviderEndpointIntegrationTests { + @Test func anonymousHTTPProviderSurvivesPersistenceReload() throws { + let container = try ModelContainer( + for: HostPersistence.schema, + configurations: [ModelConfiguration(isStoredInMemoryOnly: true, cloudKitDatabase: .none)]) + let provider = ModelProvider(name: "Local", models: [AIModel(id: "local-model")]) + let store = AIConfigurationModel(modelContext: container.mainContext, persistsSecrets: false) + #expect(store.saveProvider(provider, endpoint: ProviderEndpoint(url: "http://localhost:11434/v1"))) + let reloaded = AIConfigurationModel(modelContext: container.mainContext, persistsSecrets: false) + let saved = try #require(reloaded.modelProvider(provider.id)) + #expect(saved.credentialRef.isEmpty) + #expect(saved.inferenceURL == "http://localhost:11434/v1/chat/completions") + #expect(saved.models.map(\.id) == ["local-model"]) + #expect(saved.canFetchModels) + #expect(reloaded.modelSecret(for: saved) == "") + #expect(CommandGeneratorController(ai: reloaded).isLocallyAvailable) + } + /// The round trip the editor depends on: what was typed comes back, and so /// does the difference between `Automatic` and picking the same format. @Test diff --git a/TFMTests/ProviderHTTPIntegrationTests.swift b/TFMTests/ProviderHTTPIntegrationTests.swift new file mode 100644 index 00000000..f8cba711 --- /dev/null +++ b/TFMTests/ProviderHTTPIntegrationTests.swift @@ -0,0 +1,32 @@ +import AgentKit +import Foundation +import TermindApplication +import Testing + +/// Opt-in real-network smoke test against a disposable compatible model server. +/// TERMIND_MODEL_HTTP_TEST_URLS is a comma-separated list of base URLs. The server +/// answers ordinary prompts with OK and honors the title/command JSON contracts. +@Suite @MainActor +struct ProviderHTTPIntegrationTests { + @Test func nativeAppCanUseAnonymousHTTPEndpoints() async throws { + guard let urls = ProcessInfo.processInfo.environment["TERMIND_MODEL_HTTP_TEST_URLS"] else { return } + for base in urls.split(separator: ",").map(String.init) { + let provider = ModelProvider( + name: "Anonymous HTTP", inferenceURL: base + "/chat/completions", baseURL: base, + models: [AIModel(id: "test")]) + let ai = AIConfigurationModel(modelProviders: [provider], persistsSecrets: false) + let secret = try #require(ai.modelSecret(for: provider)) + #expect(secret.isEmpty) + let models = try await ModelCatalogClient().models(for: provider, secret: secret) + #expect(models.map(\.id) == ["test"]) + let probe = try await ChatProbe().run(model: provider.models[0], on: provider, secret: secret) + #expect(probe.reply == "OK") + let command = CommandGeneratorController(ai: ai) + #expect(command.isLocallyAvailable) + #expect(try await command.generate(.init(draft: "say OK", shell: "zsh", cwd: "/tmp")) == "echo OK") + let title = try await ConversationTitleController(ai: ai).summarize( + .init(firstUserMessage: "Say OK", firstAssistantMessage: "OK")) + #expect(title == "Local model") + } + } +} diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index bf68b624..7db287da 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2622,7 +2622,7 @@ repositoryURL = "https://github.com/AkinoKaede/AgentKit.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.13.0; + minimumVersion = 0.13.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index acd6f421..2de1333b 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "10cd8ef715c3ea5e1338969f030ed94b372888881ea925093c1fed2cfdb2508d", + "originHash" : "6aeb5447857612da7b6960feb17ce0fc14e8688afe3bd4e0b56c885ecc186b9f", "pins" : [ { "identity" : "agentkit", "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/AgentKit.git", "state" : { - "revision" : "f02825a443edfc241116cdc2a513fc7c9fc3dc17", - "version" : "0.13.0" + "revision" : "bbc233cf6cf4dc28c3f6e7d9b7b06d096f7b27f7", + "version" : "0.13.1" } }, {