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:
Kaede Akino committed 2026-09-17 08:52:16 +08:00
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 }
}
+23
View File
@@ -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? {
+16
View File
@@ -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) }
+7
View File
@@ -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)
+55 -4
View File
@@ -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")
+1
View File
@@ -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")
}
}
}
+17 -9
View File
@@ -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")
+7
View File
@@ -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")
}
}
}
+1 -1
View File
@@ -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"
}
},
{