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.
This commit is contained in:
1 parent
1cc17a8e96
commit
4ea707083f
26 files changed
+411
-44
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Termind connects to servers and model providers on your local network.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Termind connects to servers and model providers on your local network.</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user