From ea3aae9b528773aac254d68fcd94b92e0df2604e Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 4 Sep 2026 11:33:34 +0800 Subject: [PATCH] Address a provider by one URL and one API type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API Type picker named three vendors — OpenAI, Anthropic, Google — for four wire protocols, so `.openAI` carried a Responses toggle to express the fourth. Each kind also carried a default base URL, which the editor printed as the Base URL placeholder, so `api.openai.com` was on screen for anyone who had added a provider and typed nothing, and a provider with a key but no address quietly resolved to a vendor endpoint nobody had named. The picker now names the protocol: Automatic, Chat Completions, Responses, Messages, generateContent. Automatic reads the protocol off the URL's own suffix and falls back to Chat Completions when nothing matches, which is what an endpoint that does not name itself is almost always serving. Base URL and API Path collapse into one field. Splitting them made "does the base URL include /v1" a question the user had to answer, and it is the question every client of this shape gets wrong. Paste whatever the gateway's documentation printed, at whatever depth it printed it; the two captions underneath say what that produced — Full URL is read from the same value the request is built from, and the line under it names the body shape, which is how Automatic explains its own answer and how an explicit pick that disagrees with the URL stops being a silent surprise. An explicit pick moves the format, never the address: the person who pasted it keeps it. Vertex AI goes with the vendor names. It stays in AgentKit, which is an independent package and does not carry this app's constraints, but nothing here drives it any more — the editor's project, location and service-account import are gone, along with five persisted columns and their Keychain path. `ProviderEndpoint` holds the two things the editor owns, the typed URL and the picked type, because neither survives a round trip through the composed result: a URL ending in /messages looks the same whether it was typed or appended, and Automatic looks the same as picking the format it resolved to. `AIConfigurationModel.setEndpoint` is the only writer of the derived `inferenceURL`, `baseURL` and `apiFormat`. Old rows fold back to the address they used to resolve to rather than to Automatic, so a provider that worked before the change points at the same place after it. That fold needs the pre-V12 columns, which is why `kind`, `baseURL`, `apiPath` and `usesResponsesAPI` are still declared, and it needs V11 frozen: every VersionedSchema in the plan points at live model types, so editing a persisted model rewrites what the old version *means* and leaves an installed store recording a version the plan no longer contains. That is a refusal to open, not a migration. `TermindSchemaV11Models` is what gives V11 to V12 two sides — any future attribute change needs the same freeze. Requires AgentKit 0.5.0, where `ModelAPIFormat` replaces `ModelProviderKind` and a provider carries `inferenceURL` and `baseURL` instead of a kind, a base and a path. Verified with the full test suite (994 tests, including the new ProviderEndpointTests), swift-format lint --strict, Scripts/check-zh-localization.sh, npm run build, and a launch of the app against an existing V11 store. Co-Authored-By: Claude Opus 5 (1M context) --- Memory/REFACTOR.md | 59 +++++ Termind.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../AIConfigurationModel+Demo.swift | 50 ++-- .../FeatureModels/AIConfigurationModel.swift | 52 +++- .../AIConfigurationPersistence.swift | 2 + Termind/Domain/AI/ModelAPIType.swift | 225 +++++++++++++++++ .../Features/Settings/ModelListEditor.swift | 11 +- .../Settings/ModelProviderEditorSheet.swift | 229 +++++------------- .../Settings/ModelProviderSettings.swift | 26 +- .../Features/Settings/SettingsPageView.swift | 3 +- .../Database/AIPersistence.swift | 144 ++++++++--- .../Database/HostPersistence.swift | 32 ++- Termind/Resources/Localizable.xcstrings | 200 ++++----------- TermindTests/AIProviderPersistenceTests.swift | 51 ++-- TermindTests/AgentTokenUsageTests.swift | 2 +- TermindTests/ProviderEndpointTests.swift | 220 +++++++++++++++++ TermindTests/SettingsNavigationTests.swift | 2 +- TermindTests/WebSearchTests.swift | 8 +- 19 files changed, 893 insertions(+), 429 deletions(-) create mode 100644 Termind/Domain/AI/ModelAPIType.swift create mode 100644 TermindTests/ProviderEndpointTests.swift diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 67e1a292..403c98fa 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -890,3 +890,62 @@ The arrow cases match on key code rather than character, as `.tab` and `.escape` `PersistentTerminalSurfaceView.TermindTerminalView.performKeyEquivalent` and decline it, so Ghostty writes no escape sequence to the shell; `BroadcastInputTests.registeredApplicationShortcutsFallThroughTheTerminal` now covers both arrows. + +### 48. Address a provider by one URL and one API type — Structural + +**Status:** Completed on 2026-09-04; AgentKit `swift build`/`swift test`/`swift-format lint --strict` pass, the +macOS build and all 994 Termind tests pass, `Scripts/check-zh-localization.sh` passes, and `npm run build` passes +for the site. + +**Evidence:** + +- `ModelProviderKind` named three vendors for four wire protocols, so `.openAI` had to carry `usesResponsesAPI` to + express the fourth, and every dispatch site wrote `case .openAI where provider.usesResponsesAPI` before the three + ordinary cases. +- Each kind carried a `defaultBaseURL` and a `defaultAPIPath`, which a blank field fell back to. The provider + editor printed those defaults as placeholders, so `https://api.openai.com/v1` was on screen for anyone who had + added a provider and typed nothing — and a provider with a credential but no address resolved to a vendor + endpoint nobody had named. +- The address was two fields, base and path, which made "does the base URL include `/v1`" a question the user had + to answer correctly. + +**Action taken:** AgentKit gained `ModelAPIFormat` (`chatCompletions`, `responses`, `messages`, +`generateContent`) and two named addresses: `inferenceURL`, the complete address a request goes to, and +`baseURL`, that address with its endpoint segment removed, which is what `ModelCatalogClient` hangs `/models` +off. Neither is composed or defaulted there. Termind owns the `Automatic` choice and the suffix detection behind +it (`ModelAPIType`, `ProviderEndpoint`), composes both addresses, and shows one URL field with the composed +address and body format underneath it. + +Removing the endpoint segment has to be the caller's job because only the caller knows which part it appended, +and `/v1/chat/completions` and `/v1beta/models/{model}:generateContent` do not put their endpoints in comparable +places — cutting at the colon leaves the model id behind and lists from `…/models/gemini-x/models`. Shipped as +AgentKit 0.4.0 and then 0.5.0, the second being that split and the rename it forced: with two addresses on a +provider, `url` no longer says which one. + +**Net effect:** AgentKit measured +224/−205 including tests, with `ModelProvider.swift` down 203 lines to 121. +Termind removed the Vertex editor section, the service-account import, the API-path field, the Responses toggle, +and 15 string-catalog entries against 5 added. + +**Verification:** `ProviderEndpointTests` pins the composition rule, including that an explicit API type changes +the body without moving the address, that a query stays at the end of whatever is composed, and that every +pre-V12 row folds back to the address it used to resolve to. + +Two things did not go as planned. `TermindSchemaV12` first reused `TermindSchemaV11.models` verbatim, which +SwiftData rejected with "Duplicate version checksums detected" — every `VersionedSchema` in the plan points at +live model types, so two versions built from the same types hash identically. Dropping V12 then produced "Cannot +use staged migration with an unknown model version", because an installed store records the checksum of the shape +it was saved with and V11 no longer described it. `TermindSchemaV11Models.PersistentModelProvider` freezes the old +shape, following `TermindSchemaV9Models`; that is what gives V11→V12 two sides. Any future attribute change to a +persisted model needs the same freeze. + +A guard added to `AIConfigurationModel.lookup` — skip providers with no URL — was removed again. It made a +provider with hand-added models vanish from every picker, which is exactly what the `enabledModelProviders` note +argues against, and the hazard it was written for no longer exists: with no default endpoint in AgentKit, a blank +URL fails with a bad-URL error naming the problem instead of reaching a vendor. + +### 33 addendum (2026-09-04) + +`usesVertexEndpoint` now reads `usesVertex && apiFormat == .generateContent`; `kind` is gone. Vertex itself stays +in AgentKit, which is an independent package and does not carry the app's constraints — but nothing in Termind +drives it any more, so `AgentProviderAdapterTests.vertexDerivesItsOwnAddressAndDeclinesTheCatalog` is the only +thing still holding it honest. `effectiveCredentialRef` is unchanged and still earns its place. diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index d548330d..635a7693 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -673,7 +673,7 @@ repositoryURL = "https://github.com/AkinoKaede/AgentKit.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.3.0; + minimumVersion = 0.5.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 519a6e8c..34ed7bed 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/AgentKit.git", "state" : { - "revision" : "00ff969af518d817062dd3d519336f928e167d61", - "version" : "0.3.0" + "revision" : "a7345ee68fd92b52589cecfc812517c089f39368", + "version" : "0.5.0" } }, { diff --git a/Termind/Application/FeatureModels/AIConfigurationModel+Demo.swift b/Termind/Application/FeatureModels/AIConfigurationModel+Demo.swift index ad2159fc..e74c6f74 100644 --- a/Termind/Application/FeatureModels/AIConfigurationModel+Demo.swift +++ b/Termind/Application/FeatureModels/AIConfigurationModel+Demo.swift @@ -18,28 +18,38 @@ extension AIConfigurationModel { /// models at all, so it walks past it — and seeding an assignment would put /// that out of reach. static func demo() -> AIConfigurationModel { - // The fresh state: added, never finished. Fetch stays disabled until a - // key exists, which is the first thing anyone needs to see. - let openAI = ModelProvider(name: "OpenAI", kind: .openAI) + // The fresh state: added, never finished. With no URL it reads as + // "Needs a URL", which is the first thing anyone has to see. + let unfinished = ModelProvider(name: "Local") - // The working state. An OpenAI-kind provider pointed somewhere else, - // which is the whole reason the base URL is editable — a gateway is not - // a fourth API type. - let openRouterKey = AIConfigurationModel.makeCredentialRef() - var openRouter = ModelProvider( - name: "OpenRouter", - kind: .openAI, - baseURL: "https://openrouter.ai/api/v1", - credentialRef: openRouterKey + // The working state, and the one that shows why the URL is a single + // field: a base address with no endpoint on it, resolved to Chat + // Completions because nothing in it said otherwise. + let gatewayKey = AIConfigurationModel.makeCredentialRef() + let gatewayEndpoint = ProviderEndpoint(url: "https://ai.termind.akinokaede.com/v1") + var gateway = ModelProvider( + name: "Gateway", + apiFormat: gatewayEndpoint.format, + inferenceURL: gatewayEndpoint.requestURL, + baseURL: gatewayEndpoint.baseURL, + credentialRef: gatewayKey ) // No `modelsFetchedAt`: nothing has been fetched on a fresh install, and // a "Fetched last year" line under a list that looks current reads as a // bug rather than as seed data. - openRouter.models = demoModels + gateway.models = demoModels - // Google with Vertex off, so both halves of that toggle are one click - // apart rather than one of them being unreachable at P0. - let gemini = ModelProvider(name: "Gemini", kind: .google) + // A second shape, so the API Type column is not one value repeated — + // and one recognised from the URL's own suffix rather than picked. + let workEndpoint = ProviderEndpoint( + url: "https://ai.termind.akinokaede.com/anthropic/v1/messages" + ) + let work = ModelProvider( + name: "Work", + apiFormat: workEndpoint.format, + inferenceURL: workEndpoint.requestURL, + baseURL: workEndpoint.baseURL + ) let mcp = MCPServer( name: "Context7", @@ -52,12 +62,14 @@ extension AIConfigurationModel { ) let store = AIConfigurationModel( - modelProviders: [openAI, openRouter, gemini], mcpServers: [mcp], + modelProviders: [unfinished, gateway, work], + endpoints: [gateway.id: gatewayEndpoint, work.id: workEndpoint], + mcpServers: [mcp], persistsSecrets: false ) // A stand-in so the working provider actually looks configured. Gone // when the app quits, like every other secret at P0. - store.setSecret("sk-or-v1-demo-key-not-real", for: openRouterKey) + store.setSecret("demo-key-not-real", for: gatewayKey) return store } @@ -112,7 +124,7 @@ extension AIConfigurationModel { bare.ownedBy = "openai" bare.origin = .fetched - let provider = ModelProvider(name: "OpenAI") + let provider = ModelProvider(name: "Gateway") return [ opus, gemini, diff --git a/Termind/Application/FeatureModels/AIConfigurationModel.swift b/Termind/Application/FeatureModels/AIConfigurationModel.swift index 5b1238e8..e89adab7 100644 --- a/Termind/Application/FeatureModels/AIConfigurationModel.swift +++ b/Termind/Application/FeatureModels/AIConfigurationModel.swift @@ -11,6 +11,21 @@ import Foundation @Observable final class AIConfigurationModel { private(set) var modelProviders: [ModelProvider] = [] + + /// What the user typed and picked for each provider's endpoint. + /// + /// Beside the providers rather than on them, because `ModelProvider` is + /// AgentKit's type and this app does not get to add a field to it. Neither + /// half survives a round trip through the composed result — a URL ending in + /// `/messages` looks the same whether it was typed or appended, and + /// `Automatic` looks the same as picking the format it resolved to — so + /// there is nothing to derive it from and it has to be kept. + /// + /// A missing entry is not a broken one: it reads as `Automatic` over the + /// provider.s stored `inferenceURL`, which resolves to the format already recorded + /// there. The editor shows the same endpoint either way. + private var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] + private(set) var mcpServers: [MCPServer] = [] /// Explicit role assignments. Absent means "work it out" — see `resolve`. @@ -50,6 +65,7 @@ final class AIConfigurationModel { init( modelProviders: [ModelProvider] = [], + endpoints: [ModelProvider.ID: ProviderEndpoint] = [:], mcpServers: [MCPServer] = [], roles: [AIRole: AIModelRef] = [:], lastSelectedModel: AIModelRef? = nil, @@ -60,6 +76,7 @@ final class AIConfigurationModel { permissionDefaults: UserDefaults? = nil ) { self.modelProviders = modelProviders + self.endpoints = endpoints self.mcpServers = mcpServers self.roles = roles self.lastSelectedModel = lastSelectedModel @@ -76,6 +93,7 @@ final class AIConfigurationModel { do { let loaded = try persistence.load() self.modelProviders = loaded.providers + self.endpoints = loaded.endpoints self.roles = loaded.roles self.lastSelectedModel = loaded.lastSelectedModel self.mcpServers = loaded.mcpServers @@ -90,6 +108,13 @@ final class AIConfigurationModel { modelProviders.first { $0.id == id } } + /// What the editor shows for a provider's endpoint. Falls back to reading + /// the composed URL as `Automatic` — see `endpoints`. + func endpoint(for id: ModelProvider.ID) -> ProviderEndpoint { + if let stored = endpoints[id] { return stored } + return ProviderEndpoint(url: modelProvider(id)?.inferenceURL ?? "") + } + /// Providers whose models may be offered anywhere. A provider with no /// credential is included: it may have hand-added models, and hiding it /// would make "why is my model missing" unanswerable from the picker. @@ -102,6 +127,13 @@ final class AIConfigurationModel { /// /// Every step of `resolve` runs through here, which is what stops a stale /// assignment from silently pointing at nothing. + /// + /// A missing URL is deliberately *not* one of the disqualifying conditions, + /// for the same reason a missing credential is not: the model is sitting + /// right there in Settings, and a picker that quietly skips it makes "why is + /// my model missing" unanswerable. Sending to a blank URL now fails with a + /// bad-URL error naming the problem, which is the better of the two — there + /// is no default endpoint left for it to reach instead. func lookup(_ ref: AIModelRef) -> (provider: ModelProvider, model: AIModel)? { guard let provider = modelProvider(ref.modelProviderID), @@ -252,6 +284,22 @@ final class AIConfigurationModel { } } + /// The only place `url` and `apiFormat` are ever written. + /// + /// Everything else edits a name, a key or a model list. Keeping the + /// derivation in one function is what lets the caption under the URL field + /// promise where requests land: there is exactly one rule turning a typed + /// address and a picked type into an address, and both the caption and the + /// request read it from the same place. + func setEndpoint(_ endpoint: ProviderEndpoint, for id: ModelProvider.ID) { + guard var provider = modelProvider(id) else { return } + provider.inferenceURL = endpoint.requestURL + provider.baseURL = endpoint.baseURL + provider.apiFormat = endpoint.format + endpoints[id] = endpoint + upsert(provider) + } + /// Removes a provider along with everything that only made sense while it /// existed: its secrets, the role assignments pointing at it, and the /// remembered selection if that was one of its models. @@ -270,10 +318,10 @@ final class AIConfigurationModel { lastSelectedModel = nil } modelProviders.removeAll { $0.id == modelProviderID } + endpoints[modelProviderID] = nil } guard deleted else { return } setSecret(nil, for: provider.credentialRef) - setSecret(nil, for: provider.vertex.credentialRef) } func setModelProviderEnabled(_ enabled: Bool, for id: ModelProvider.ID) { @@ -401,6 +449,7 @@ final class AIConfigurationModel { return true } catch { modelProviders = previous.providers + endpoints = previous.endpoints roles = previous.roles lastSelectedModel = previous.lastSelectedModel mcpServers = previous.mcpServers @@ -412,6 +461,7 @@ final class AIConfigurationModel { private var persistedSnapshot: AIConfigurationSnapshot { AIConfigurationSnapshot( providers: modelProviders, + endpoints: endpoints, roles: roles, lastSelectedModel: lastSelectedModel, mcpServers: mcpServers diff --git a/Termind/Application/Repositories/AIConfigurationPersistence.swift b/Termind/Application/Repositories/AIConfigurationPersistence.swift index fd61cff7..adfba8c7 100644 --- a/Termind/Application/Repositories/AIConfigurationPersistence.swift +++ b/Termind/Application/Repositories/AIConfigurationPersistence.swift @@ -3,6 +3,8 @@ import Foundation nonisolated struct AIConfigurationSnapshot: Sendable { var providers: [ModelProvider] + /// What was typed and picked per provider — see `AIConfigurationModel.endpoints`. + var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] var roles: [AIRole: AIModelRef] var lastSelectedModel: AIModelRef? var mcpServers: [MCPServer] = [] diff --git a/Termind/Domain/AI/ModelAPIType.swift b/Termind/Domain/AI/ModelAPIType.swift new file mode 100644 index 00000000..2800673a --- /dev/null +++ b/Termind/Domain/AI/ModelAPIType.swift @@ -0,0 +1,225 @@ +import AgentKit +import Foundation + +/// What the API Type picker offers. +/// +/// The four cases past `.automatic` mirror `ModelAPIFormat` one for one. +/// `.automatic` is not a fifth request shape — it is a *choice*, "read the +/// shape off the URL I pasted" — which is why it lives here rather than in +/// AgentKit: nothing that builds a request can act on it, and by the time one +/// does the choice has already been resolved to a format. +nonisolated enum ModelAPIType: String, Codable, Sendable, CaseIterable, Identifiable { + /// First because it is the answer for almost everyone: people paste the URL + /// their gateway's documentation gave them, and that URL already says which + /// endpoint it is. + case automatic + case chatCompletions + case responses + case messages + case generateContent + + nonisolated var id: String { rawValue } + + /// `nil` for `.automatic`, which is the whole difference between the two + /// types — see `ProviderEndpoint.format` for how it is filled in. + var format: ModelAPIFormat? { + switch self { + case .automatic: nil + case .chatCompletions: .chatCompletions + case .responses: .responses + case .messages: .messages + case .generateContent: .generateContent + } + } + + init(_ format: ModelAPIFormat) { + switch format { + case .chatCompletions: self = .chatCompletions + case .responses: self = .responses + case .messages: self = .messages + case .generateContent: self = .generateContent + } + } + + var displayName: String { + switch self { + case .automatic: String(localized: "Automatic") + default: format?.displayName ?? "" + } + } + + var symbol: String { + switch self { + case .automatic: "wand.and.stars" + default: format?.symbol ?? "" + } + } +} + +nonisolated extension ModelAPIFormat { + /// Left in English on purpose. These are the endpoint names a gateway's own + /// documentation prints, and translating them would mean the label in + /// Settings no longer matches the page the URL was copied from. + var displayName: String { + switch self { + case .chatCompletions: "Chat Completions" + case .responses: "Responses" + case .messages: "Messages" + case .generateContent: "generateContent" + } + } + + var symbol: String { + switch self { + case .chatCompletions: "bubble.left.and.bubble.right" + case .responses: "arrow.left.arrow.right" + case .messages: "message" + case .generateContent: "diamond" + } + } + + /// Appended to a URL that does not already name an endpoint. + var path: String { + switch self { + case .chatCompletions: "/chat/completions" + case .responses: "/responses" + case .messages: "/messages" + // The only one that needs the token: this shape addresses the model in + // the path rather than in the body. + case .generateContent: "/models/\(ModelProvider.modelToken):generateContent" + } + } + + /// A typed URL taken apart the way composing an address needs it. + nonisolated struct URLParts: Hashable, Sendable { + /// The path as typed, minus any trailing slash and without the query. + /// What a request keeps using when the URL already named its endpoint. + var path: String = "" + /// `path` with the recognised endpoint removed, or `path` itself when + /// none was recognised. Where `/models` gets appended. + var base: String = "" + /// `?…`, kept aside so an appended path does not land behind it. + var query: String = "" + /// The endpoint the URL names, if it names one. + var endpoint: ModelAPIFormat? + } + + /// The endpoint a pasted URL names, if it names one. + static func detected(in url: String) -> ModelAPIFormat? { + parts(of: url).endpoint + } + + /// Suffix matching rather than anything cleverer, because a gateway is free + /// to serve `/anthropic/v1/messages` or `/openai/v1/chat/completions` and + /// only the tail is common to all of them. + /// + /// `generateContent` needs more than a fixed suffix: it names the model + /// *inside* the path, so what has to come off to reach the base runs from + /// the `/models/` segment rather than from the colon. Cutting at the colon + /// instead would leave the model id behind and send `Fetch Models` to + /// `…/models/gemini-x/models`. Streaming counts as the same endpoint — + /// `:streamGenerateContent` carries the same body, and someone who pasted + /// it has still said which shape they meant. + static func parts(of url: String) -> URLParts { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + let queryStart = trimmed.firstIndex(of: "?") + var path = queryStart.map { String(trimmed[trimmed.startIndex..<$0]) } ?? trimmed + let query = queryStart.map { String(trimmed[$0...]) } ?? "" + while path.hasSuffix("/") { path.removeLast() } + + let endsWithGenerateContent = + path.range(of: "generateContent", options: [.anchored, .backwards, .caseInsensitive]) + != nil + if endsWithGenerateContent, + let models = path.range(of: "/models/", options: [.backwards, .caseInsensitive]) + { + return URLParts( + path: path, base: String(path[path.startIndex.. some View { + private var endpointSection: some View { + let endpoint = endpoint + Section { TextField( - "Base URL", - text: binding(\.baseURL), - prompt: Text(provider.kind.defaultBaseURL) + "URL", + text: endpointBinding(\.url), + prompt: Text(verbatim: "https://ai.termind.akinokaede.com/v1") ) - TextField( - "API Path", - text: binding(\.apiPath), - prompt: Text(provider.kind.defaultAPIPath(usesResponsesAPI: provider.usesResponsesAPI)) - ) + if endpoint.isConfigured { + LabeledContent("Full URL") { + Text(endpoint.requestURL) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + } - if provider.kind == .openAI { - // Only OpenAI has two chat endpoints. A toggle that did nothing - // on the other two would be noise in the place noise costs most. - Toggle("Responses API", isOn: binding(\.usesResponsesAPI)) - } - - LabeledContent("Full URL") { - Text(provider.resolvedRequestURL()) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .multilineTextAlignment(.trailing) - .fixedSize(horizontal: false, vertical: true) - } - - if provider.resolvedAPIPath.contains(ModelProvider.modelToken) { - Text("\(ModelProvider.modelToken) is replaced with the model id.") + Text("Requests use the \(endpoint.format.displayName) format.") .font(.caption) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if endpoint.requestURL.contains(ModelProvider.modelToken) { + Text("\(ModelProvider.modelToken) is replaced with the model id.") + .font(.caption) + .foregroundStyle(.secondary) + } } } header: { Text("Endpoint") @@ -197,74 +179,6 @@ struct ModelProviderEditorPage: View { } } - /// Vertex replaces the endpoint *and* the credential, which is why it gets - /// its own section rather than an extra field: with it on, there is no base - /// URL to type — the address is derived from the project and the location. - @ViewBuilder - private func vertexSection(_ provider: ModelProvider) -> some View { - Section { - TextField("Project ID", text: binding(\.vertex.projectID)) - TextField("Location", text: binding(\.vertex.location), prompt: Text("global")) - - LabeledContent("Full URL") { - Text(provider.resolvedRequestURL()) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .multilineTextAlignment(.trailing) - .fixedSize(horizontal: false, vertical: true) - } - } header: { - Text("Vertex AI") - } - - Section { - if provider.vertex.clientEmail.isEmpty { - Text("No service account yet.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - LabeledContent("Service Account") { - Text(provider.vertex.clientEmail) - .font(.caption.monospaced()) - .lineLimit(1) - .truncationMode(.middle) - } - } - - HStack { - Button("Import JSON") { session.isImporting = true } - if !session.serviceAccountJSON.isEmpty { - Button("Remove", role: .destructive) { clearServiceAccount() } - } - } - - if let importError = session.importError { - Label(importError, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - - Text( - "Termind reads the account name and stores the remaining key data as a secret." - ) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } header: { - Text("Credential") - } - .fileImporter( - isPresented: Binding( - get: { session.isImporting }, set: { session.isImporting = $0 } - ), - allowedContentTypes: [.json] - ) { result in - importServiceAccount(result) - } - } - // MARK: - Secrets private func loadSecrets() { @@ -293,54 +207,27 @@ struct ModelProviderEditorPage: View { app.aiConfiguration.upsert(provider) } - private func importServiceAccount(_ result: Result) { - session.importError = nil - do { - let url = try result.get() - // The panel hands back a security-scoped URL; without the accessor - // the read fails silently under the sandbox. - let scoped = url.startAccessingSecurityScopedResource() - defer { if scoped { url.stopAccessingSecurityScopedResource() } } - - let json = try String(contentsOf: url, encoding: .utf8) - guard let email = GoogleServiceAccountAuth.clientEmail(in: json) else { - session.importError = GoogleAuthError.malformedServiceAccount.localizedDescription - return - } - - guard var provider else { return } - if provider.vertex.credentialRef.isEmpty { - provider.vertex.credentialRef = AIConfigurationModel.makeCredentialRef("vertex") - } - provider.vertex.clientEmail = email - // The file names the project it belongs to, so a field the user - // would otherwise copy across by hand fills itself — and fills with - // the value that matches the key rather than a different one. - if provider.vertex.projectID.isEmpty, - let project = GoogleServiceAccountAuth.projectID(in: json) - { - provider.vertex.projectID = project - } - app.aiConfiguration.setSecret(json, for: provider.vertex.credentialRef) - app.aiConfiguration.upsert(provider) - session.serviceAccountJSON = json - } catch { - session.importError = error.localizedDescription - } - } - - private func clearServiceAccount() { - guard var provider else { return } - app.aiConfiguration.setSecret(nil, for: provider.vertex.credentialRef) - provider.vertex.credentialRef = "" - provider.vertex.clientEmail = "" - app.aiConfiguration.upsert(provider) - session.serviceAccountJSON = "" - session.importError = nil - } - // MARK: - Binding + private var endpoint: ProviderEndpoint { + app.aiConfiguration.endpoint(for: modelProviderID) + } + + /// A two-way binding into the endpoint, written through the store's one + /// derivation so the composed URL and the API format are updated together. + private func endpointBinding( + _ keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { endpoint[keyPath: keyPath] }, + set: { newValue in + var updated = endpoint + updated[keyPath: keyPath] = newValue + app.aiConfiguration.setEndpoint(updated, for: modelProviderID) + } + ) + } + /// A two-way binding into the stored provider. /// /// Reads and writes go through `AIConfigurationModel` rather than through a local copy, diff --git a/Termind/Features/Settings/ModelProviderSettings.swift b/Termind/Features/Settings/ModelProviderSettings.swift index 48acd071..0aac7268 100644 --- a/Termind/Features/Settings/ModelProviderSettings.swift +++ b/Termind/Features/Settings/ModelProviderSettings.swift @@ -61,18 +61,24 @@ private struct ModelProviderRow: View { var body: some View { HStack(spacing: Metrics.Space.m) { - Image(systemName: provider.kind.symbol) + Image(systemName: provider.apiFormat.symbol) .foregroundStyle(provider.isEnabled ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) .frame(width: Metrics.Sidebar.rowIcon) VStack(alignment: .leading, spacing: 1) { Text(provider.name) .lineLimit(1) - Text(provider.resolvedBaseURL) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) + Group { + if endpoint.isConfigured { + Text(endpoint.url) + } else { + Text("Needs a URL") + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) } Spacer(minLength: Metrics.Space.m) @@ -97,7 +103,11 @@ private struct ModelProviderRow: View { // The whole row opens the editor; the toggle and the chevron are // ordinary controls layered on top and keep their own hit areas. .onTapGesture(perform: edit) - .help(Text(provider.resolvedRequestURL())) + .help(Text(endpoint.isConfigured ? provider.requestURL() : "")) + } + + private var endpoint: ProviderEndpoint { + app.aiConfiguration.endpoint(for: provider.id) } private var enabled: Binding { @@ -114,7 +124,7 @@ private struct ModelProviderRow: View { /// state on the way to a finished one, not an error. @ViewBuilder private var chips: some View { - CardChip(text: provider.kind.displayName) + CardChip(text: provider.apiFormat.displayName) if !provider.hasCredential { CardChip(text: String(localized: "No Key"), symbol: "exclamationmark.circle", tint: .orange) diff --git a/Termind/Features/Settings/SettingsPageView.swift b/Termind/Features/Settings/SettingsPageView.swift index 9a6c6aca..ba39422c 100644 --- a/Termind/Features/Settings/SettingsPageView.swift +++ b/Termind/Features/Settings/SettingsPageView.swift @@ -298,8 +298,7 @@ enum SettingsDraftPolicy { provider.name.trimmingCharacters(in: .whitespacesAndNewlines) == String(localized: "New Provider") let hasConfiguration = - !provider.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || !provider.apiPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + !provider.inferenceURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || provider.hasCredential || !provider.models.isEmpty return hasDefaultName && !hasConfiguration } diff --git a/Termind/Infrastructure/Database/AIPersistence.swift b/Termind/Infrastructure/Database/AIPersistence.swift index bd3fc75f..fa45b992 100644 --- a/Termind/Infrastructure/Database/AIPersistence.swift +++ b/Termind/Infrastructure/Database/AIPersistence.swift @@ -2,63 +2,133 @@ import AgentKit import Foundation import SwiftData +/// The provider row as schema V11 stored it: a vendor-named kind, an address +/// split into a base and a path, and the Vertex fields. +/// +/// Frozen rather than described in prose, for the reason +/// `TermindSchemaV9Models` is: every `VersionedSchema` in the plan points at +/// live model types, so editing the current `PersistentModelProvider` would +/// silently rewrite what V11 *means* and leave an installed store recording a +/// version the plan no longer contains — which is a refusal to open, not a +/// migration. Keeping the old shape here is what gives V11→V12 two sides. +enum TermindSchemaV11Models { + @Model + final class PersistentModelProvider { + @Attribute(.unique) var id: UUID = UUID() + var name = "" + var kind = "openAI" + var isEnabled = true + var baseURL = "" + var apiPath = "" + var credentialRef = "" + var usesResponsesAPI = false + var usesVertex = false + var vertexProjectID = "" + var vertexLocation = "global" + var vertexCredentialRef = "" + var vertexClientEmail = "" + var modelsFetchedAt: Date? + var sortIndex = 0 + + init() {} + } +} + @Model final class PersistentModelProvider { @Attribute(.unique) var id: UUID = UUID() var name = "" - var kind = ModelProviderKind.openAI.rawValue + /// `ModelAPIType.rawValue` — what the editor's picker is set to. Blank + /// marks a row written before this column existed; see `legacyFold`. + var apiType = "" + /// The address as the user typed it. + /// + /// The *composed* address and the API format are not stored beside it. They + /// are derived on read by the same `ProviderEndpoint` the editor writes + /// through, so there is nothing for them to drift away from — one rule, + /// applied in one place, whether the value is being shown or sent. + var typedURL = "" var isEnabled = true - var baseURL = "" - var apiPath = "" var credentialRef = "" - var usesResponsesAPI = false - var usesVertex = false - var vertexProjectID = "" - var vertexLocation = "global" - var vertexCredentialRef = "" - var vertexClientEmail = "" var modelsFetchedAt: Date? var sortIndex = 0 - init(_ value: ModelProvider, sortIndex: Int) { + // MARK: - Legacy (schema ≤ V11) + + /// Written by versions that split the address into a base and a path and + /// named the protocol after a vendor. Read once by `legacyFold` and never + /// written again; removable after the first shipped release. + var kind = "" + var baseURL = "" + var apiPath = "" + var usesResponsesAPI = false + + init(_ value: ModelProvider, endpoint: ProviderEndpoint, sortIndex: Int) { id = value.id name = value.name - kind = value.kind.rawValue + apiType = endpoint.apiType.rawValue + typedURL = endpoint.url isEnabled = value.isEnabled - baseURL = value.baseURL - apiPath = value.apiPath credentialRef = value.credentialRef - usesResponsesAPI = value.usesResponsesAPI - usesVertex = value.usesVertex - vertexProjectID = value.vertex.projectID - vertexLocation = value.vertex.location - vertexCredentialRef = value.vertex.credentialRef - vertexClientEmail = value.vertex.clientEmail modelsFetchedAt = value.modelsFetchedAt self.sortIndex = sortIndex } func value(models: [AIModel]) -> ModelProvider { - ModelProvider( + let endpoint = endpoint + return ModelProvider( id: id, name: name, - kind: ModelProviderKind(rawValue: kind) ?? .openAI, + apiFormat: endpoint.format, isEnabled: isEnabled, - baseURL: baseURL, - apiPath: apiPath, + inferenceURL: endpoint.requestURL, + baseURL: endpoint.baseURL, credentialRef: credentialRef, - usesResponsesAPI: usesResponsesAPI, - usesVertex: usesVertex, - vertex: VertexConfig( - projectID: vertexProjectID, - location: vertexLocation, - credentialRef: vertexCredentialRef, - clientEmail: vertexClientEmail - ), models: models, modelsFetchedAt: modelsFetchedAt ) } + + var endpoint: ProviderEndpoint { + guard apiType.isEmpty else { + return ProviderEndpoint( + apiType: ModelAPIType(rawValue: apiType) ?? .automatic, url: typedURL + ) + } + return legacyFold + } + + /// The pre-V12 columns read as one endpoint. + /// + /// The old shape had two fields and two fallbacks: a blank base URL meant + /// the vendor's public host and a blank path meant that vendor's default + /// endpoint. Both are gone, so the fold has to put back what they resolved + /// to — otherwise a provider that worked yesterday comes back pointing + /// nowhere. It lands on the *explicit* API type rather than `Automatic`, + /// which reproduces the old behaviour exactly instead of re-deriving it. + private var legacyFold: ProviderEndpoint { + let format: ModelAPIFormat = + switch kind { + case "anthropic": .messages + case "google": .generateContent + default: usesResponsesAPI ? .responses : .chatCompletions + } + let base = baseURL.isEmpty ? Self.legacyDefaultBaseURL(kind) : baseURL + let path = apiPath.isEmpty ? format.path : apiPath + return ProviderEndpoint(apiType: ModelAPIType(format), url: base + path) + } + + /// What `ModelProviderKind.defaultBaseURL` used to return. Kept here rather + /// than in AgentKit because nothing but this fold still needs it, and an + /// endpoint the app supplies on its own is exactly what the single URL + /// field exists to stop. + private static func legacyDefaultBaseURL(_ kind: String) -> String { + switch kind { + case "anthropic": "https://api.anthropic.com/v1" + case "google": "https://generativelanguage.googleapis.com/v1beta" + default: "https://api.openai.com/v1" + } + } } @Model @@ -212,6 +282,7 @@ final class AIPersistence { FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) ) var seenProviders: Set = [] + var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] let providers = providerRows.compactMap { row -> ModelProvider? in guard seenProviders.insert(row.id).inserted else { return nil } var seenModels: Set = [] @@ -219,6 +290,7 @@ final class AIPersistence { guard seenModels.insert(modelRow.modelID).inserted else { return nil } return modelRow.value } + endpoints[row.id] = row.endpoint return row.value(models: models) } @@ -253,6 +325,7 @@ final class AIPersistence { return AIConfigurationSnapshot( providers: providers, + endpoints: endpoints, roles: roles, lastSelectedModel: lastSelectedModel, mcpServers: mcpServers @@ -268,7 +341,14 @@ final class AIPersistence { try context.delete(model: PersistentMCPServer.self) for (providerIndex, provider) in snapshot.providers.enumerated() { - context.insert(PersistentModelProvider(provider, sortIndex: providerIndex)) + // A provider with no recorded endpoint is read back as + // `Automatic` over its composed URL, which resolves to the same + // format it already has — see `AIConfigurationModel.endpoints`. + let endpoint = + snapshot.endpoints[provider.id] ?? ProviderEndpoint(url: provider.inferenceURL) + context.insert( + PersistentModelProvider(provider, endpoint: endpoint, sortIndex: providerIndex) + ) for (modelIndex, model) in provider.models.enumerated() { context.insert( PersistentAIModel(model, providerID: provider.id, sortIndex: modelIndex) diff --git a/Termind/Infrastructure/Database/HostPersistence.swift b/Termind/Infrastructure/Database/HostPersistence.swift index b4a67f01..ff9e3626 100644 --- a/Termind/Infrastructure/Database/HostPersistence.swift +++ b/Termind/Infrastructure/Database/HostPersistence.swift @@ -471,7 +471,7 @@ enum TermindSchemaV1: VersionedSchema { PersistentPendingDeletion.self, PersistentDatabaseMetadata.self, TermindSchemaV9Models.PersistentCommandHistoryEntry.self, - PersistentModelProvider.self, + TermindSchemaV11Models.PersistentModelProvider.self, PersistentAIModel.self, PersistentAIRoleAssignment.self, PersistentAISelection.self, @@ -576,13 +576,36 @@ enum TermindSchemaV11: VersionedSchema { } } +/// `PersistentModelProvider` trades its vendor-named kind and split address for +/// one API type and one URL, and drops the Vertex columns. +/// +/// The stage is lightweight: `apiType` and `typedURL` arrive with defaults, and +/// the Vertex columns go away. What the old columns *meant* is recovered on +/// read by `PersistentModelProvider.legacyFold`, which is why `kind`, +/// `baseURL`, `apiPath` and `usesResponsesAPI` are still declared on the live +/// model — dropping them here would discard the addresses they encode before +/// anything had a chance to fold them. +/// +/// A version has to differ from the one before it to be a version at all, and +/// two schemas built from the same live types hash identically. Hence +/// `TermindSchemaV11Models` — see the note there. +enum TermindSchemaV12: VersionedSchema { + static let versionIdentifier = Schema.Version(12, 0, 0) + + static var models: [any PersistentModel.Type] { + TermindSchemaV11.models.filter { + $0 != TermindSchemaV11Models.PersistentModelProvider.self + } + [PersistentModelProvider.self] + } +} + enum TermindMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { [ TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self, TermindSchemaV4.self, TermindSchemaV5.self, TermindSchemaV6.self, TermindSchemaV7.self, TermindSchemaV8.self, TermindSchemaV9.self, - TermindSchemaV10.self, TermindSchemaV11.self, + TermindSchemaV10.self, TermindSchemaV11.self, TermindSchemaV12.self, ] } @@ -627,13 +650,16 @@ enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight( fromVersion: TermindSchemaV10.self, toVersion: TermindSchemaV11.self ), + MigrationStage.lightweight( + fromVersion: TermindSchemaV11.self, toVersion: TermindSchemaV12.self + ), ] } } @MainActor final class HostPersistence { - static let schema = Schema(versionedSchema: TermindSchemaV11.self) + static let schema = Schema(versionedSchema: TermindSchemaV12.self) let context: ModelContext diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index c4ab0722..373b63e7 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -1209,16 +1209,6 @@ } } }, - "Add Vertex models manually" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "手动添加Vertex模型" - } - } - } - }, "Added" : { "localizations" : { "zh-Hans" : { @@ -1423,16 +1413,6 @@ } } }, - "API Path" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "API路径" - } - } - } - }, "API Type" : { "localizations" : { "zh-Hans" : { @@ -1769,16 +1749,6 @@ } } }, - "Base URL" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "基础URL" - } - } - } - }, "Batch Startup" : { "localizations" : { "zh-Hans" : { @@ -4913,16 +4883,6 @@ } } }, - "global" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "全局" - } - } - } - }, "Got It" : { "comment" : "A button that dismisses the onboarding flow.", "isCommentAutoGenerated" : true, @@ -5371,16 +5331,6 @@ } } }, - "Import JSON" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "导入JSON" - } - } - } - }, "Import Key" : { "localizations" : { "zh-Hans" : { @@ -5979,16 +5929,6 @@ } } }, - "Location" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "地点" - } - } - } - }, "Longer than %lld KB." : { "localizations" : { "zh-Hans" : { @@ -6839,16 +6779,6 @@ } } }, - "No service account yet." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "尚未添加服务账户" - } - } - } - }, "No shell in the foreground" : { "localizations" : { "zh-Hans" : { @@ -7968,16 +7898,6 @@ } } }, - "Project ID" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "项目标识" - } - } - } - }, "Provider default" : { "localizations" : { "zh-Hans" : { @@ -8554,16 +8474,6 @@ } } }, - "Remove" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "移除" - } - } - } - }, "Remove %@" : { "localizations" : { "zh-Hans" : { @@ -8838,16 +8748,6 @@ } } }, - "Responses API" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Responses API" - } - } - } - }, "Restore Purchase" : { "comment" : "A button that restores a previous purchase.", "isCommentAutoGenerated" : true, @@ -9628,16 +9528,6 @@ } } }, - "Service Account" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "服务账户" - } - } - } - }, "Set baud rate" : { "localizations" : { "zh-Hans" : { @@ -10826,16 +10716,6 @@ } } }, - "Termind reads the account name and stores the remaining key data as a secret." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Termind会读取账户名称,并将其余密钥数据作为机密信息保存。" - } - } - } - }, "Termind returns only a one-shot handle to the MCP client. The value is bound to this session, destination, ssh_exec, and purpose." : { "localizations" : { "zh-Hans" : { @@ -12574,16 +12454,6 @@ } } }, - "Use Vertex AI" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "使用Vertex AI" - } - } - } - }, "Use your Secure Enclave SSH key" : { "localizations" : { "zh-Hans" : { @@ -12676,26 +12546,6 @@ } } }, - "Vertex AI" : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vertex AI" - } - } - } - }, - "Vertex AI uses a service account, project, and location instead of an API key." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vertex AI使用服务账户、项目和位置,而不是API密钥。" - } - } - } - }, "View Termind Pro" : { "comment" : "A button that opens the paywall for Termind Pro.", "isCommentAutoGenerated" : true, @@ -13135,6 +12985,56 @@ } } } + }, + "Add a URL first. Models are listed from the endpoint's own address." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请先填写URL。模型列表从端点自身的地址获取。" + } + } + } + }, + "Add a URL, then press Fetch" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "填写URL,然后点按“获取”" + } + } + } + }, + "Automatic" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动" + } + } + } + }, + "Needs a URL" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要填写URL" + } + } + } + }, + "Requests use the %@ format." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求使用%@格式。" + } + } + } } }, "version" : "1.2" diff --git a/TermindTests/AIProviderPersistenceTests.swift b/TermindTests/AIProviderPersistenceTests.swift index ba59fb46..3d8bd477 100644 --- a/TermindTests/AIProviderPersistenceTests.swift +++ b/TermindTests/AIProviderPersistenceTests.swift @@ -176,7 +176,10 @@ struct AIProviderPersistenceTests { ) ] ) - legacyContainer?.mainContext.insert(PersistentModelProvider(provider, sortIndex: 0)) + let legacyRow = TermindSchemaV11Models.PersistentModelProvider() + legacyRow.id = provider.id + legacyRow.name = provider.name + legacyContainer?.mainContext.insert(legacyRow) try legacyContainer?.mainContext.save() legacyContainer = nil @@ -216,20 +219,14 @@ struct AIProviderPersistenceTests { .high: ReasoningLevelMapping(isSupported: true, wireValue: "maximum"), ]) let provider = ModelProvider( - name: "Work OpenAI", - kind: .openAI, + name: "Work Gateway", + apiFormat: .responses, isEnabled: false, + inferenceURL: "https://gateway.example/v1/responses", + // Derived from the endpoint on the way back out, so a round trip + // fills it in even when the fixture leaves it blank. baseURL: "https://gateway.example/v1", - apiPath: "/responses", credentialRef: "termind.ai.key.reference-only", - usesResponsesAPI: true, - usesVertex: false, - vertex: VertexConfig( - projectID: "remembered-project", - location: "us-central1", - credentialRef: "termind.ai.vertex.reference-only", - clientEmail: "agent@example.iam.gserviceaccount.com" - ), models: [model], modelsFetchedAt: Date(timeIntervalSince1970: 1234) ) @@ -421,8 +418,8 @@ struct ModelCapabilityResolverTests { @Test func deepSeekV4IsRecognisedAsSearchCapable() { var provider = ModelProvider(name: "DeepSeek") - provider.baseURL = "https://api.deepseek.com/v1" - provider.usesResponsesAPI = true + provider.inferenceURL = "https://api.deepseek.com/v1" + provider.apiFormat = .responses let resolution = ModelCapabilityResolver.resolve( model: AIModel(id: "deepseek-v4-pro"), provider: provider @@ -433,7 +430,7 @@ struct ModelCapabilityResolverTests { // The older chat-completions models are excluded by the provider gate // even where the id might tempt the pattern. var legacy = ModelProvider(name: "DeepSeek Chat") - legacy.baseURL = "https://api.deepseek.com/v1" + legacy.inferenceURL = "https://api.deepseek.com/v1" #expect(!legacy.supportsNativeWebSearch(model: resolution.model)) } @@ -451,13 +448,13 @@ struct ModelCapabilityResolverTests { claude.abilities = [.toolCall, .webSearch] var gateway = ModelProvider(name: "OpenRouter") - gateway.baseURL = "https://openrouter.ai/api/v1" - gateway.usesResponsesAPI = true + gateway.inferenceURL = "https://openrouter.ai/api/v1" + gateway.apiFormat = .responses #expect(!gateway.supportsNativeWebSearch(model: claude)) // The same model on Anthropic's own API does have it. var anthropic = ModelProvider(name: "Anthropic") - anthropic.kind = .anthropic + anthropic.apiFormat = .messages #expect(anthropic.supportsNativeWebSearch(model: claude)) // And a Gemini id on an Anthropic-shaped endpoint is the same mistake @@ -467,7 +464,7 @@ struct ModelCapabilityResolverTests { #expect(!anthropic.supportsNativeWebSearch(model: gemini)) var google = ModelProvider(name: "Google") - google.kind = .google + google.apiFormat = .generateContent #expect(google.supportsNativeWebSearch(model: gemini)) } @@ -483,20 +480,20 @@ struct ModelCapabilityResolverTests { // https://api.deepseek.com/anthropic — `server_tool_use` and // `web_search_tool_result` are documented as supported there. var anthropicShaped = ModelProvider(name: "DeepSeek via Anthropic") - anthropicShaped.kind = .anthropic - anthropicShaped.baseURL = "https://api.deepseek.com/anthropic" + anthropicShaped.apiFormat = .messages + anthropicShaped.inferenceURL = "https://api.deepseek.com/anthropic" #expect(anthropicShaped.supportsNativeWebSearch(model: model)) // https://api.deepseek.com/v1/responses — `{"type": "web_search"}`. var responsesShaped = ModelProvider(name: "DeepSeek via Responses") - responsesShaped.baseURL = "https://api.deepseek.com/v1" - responsesShaped.usesResponsesAPI = true + responsesShaped.inferenceURL = "https://api.deepseek.com/v1" + responsesShaped.apiFormat = .responses #expect(responsesShaped.supportsNativeWebSearch(model: model)) // Chat Completions has no search on any shape, and the older models // never get the ability in the first place. var chat = ModelProvider(name: "DeepSeek Chat") - chat.baseURL = "https://api.deepseek.com/v1" + chat.inferenceURL = "https://api.deepseek.com/v1" #expect(!chat.supportsNativeWebSearch(model: model)) var legacyModel = AIModel(id: "deepseek-chat") @@ -641,12 +638,12 @@ struct ModelCapabilityResolverTests { @Test func providerNamespacesAndGoogleWireValuesAreResolved() { let openRouter = ModelProvider( - name: "OpenRouter", baseURL: "https://openrouter.ai/api/v1" + name: "OpenRouter", inferenceURL: "https://openrouter.ai/api/v1/chat/completions" ) let claude = ModelCapabilityResolver.resolve( model: reasoningModel("anthropic/claude-opus-4.7"), provider: openRouter ) - let google = ModelProvider(name: "Google", kind: .google) + let google = ModelProvider(name: "Google", apiFormat: .generateContent) let gemini = ModelCapabilityResolver.resolve( model: reasoningModel("gemini-3.1-pro-preview"), provider: google ) @@ -686,7 +683,7 @@ struct ModelCapabilityResolverTests { @Test func blankCustomWireValueUsesTheProviderDefault() { - let google = ModelProvider(name: "Google", kind: .google) + let google = ModelProvider(name: "Google", apiFormat: .generateContent) var model = reasoningModel("gemini-3.1-pro-preview") model.reasoningProfile = ReasoningProfile(levels: [ .low: ReasoningLevelMapping(isSupported: true, wireValue: nil), diff --git a/TermindTests/AgentTokenUsageTests.swift b/TermindTests/AgentTokenUsageTests.swift index de0b406e..8786af3a 100644 --- a/TermindTests/AgentTokenUsageTests.swift +++ b/TermindTests/AgentTokenUsageTests.swift @@ -56,7 +56,7 @@ struct AgentTokenUsageTests { @Test func chatCompletionsStreamingBodyAsksForUsage() throws { let client = AgentProviderClient( - provider: ModelProvider(name: "Compatible", kind: .openAI), + provider: ModelProvider(name: "Compatible", apiFormat: .chatCompletions), model: AIModel(id: "gpt-5"), secret: "key" ) diff --git a/TermindTests/ProviderEndpointTests.swift b/TermindTests/ProviderEndpointTests.swift new file mode 100644 index 00000000..2e2e5e78 --- /dev/null +++ b/TermindTests/ProviderEndpointTests.swift @@ -0,0 +1,220 @@ +import AgentKit +import Foundation +import SwiftData +import Testing + +@testable import Termind + +/// The rule that turns one typed address and one picked API type into a +/// request URL, a body format, and a models listing. +@Suite +@MainActor +struct ProviderEndpointTests { + /// The four cases the picker has to get right, read back the way the editor + /// reads them: what was typed stays what is shown, and the composed address + /// is what a request uses. + @Test + func automaticReadsTheFormatOffTheURLAndFallsBackToChatCompletions() { + let cases: [(url: String, format: ModelAPIFormat, request: String)] = [ + // No endpoint named — Chat Completions, and the path is appended. + ( + "https://x.test/v1", .chatCompletions, + "https://x.test/v1/chat/completions" + ), + // Named, so the address is used exactly as it was typed. + ("https://x.test/v1/chat/completions", .chatCompletions, "https://x.test/v1/chat/completions"), + ("https://x.test/v1/responses", .responses, "https://x.test/v1/responses"), + ("https://x.test/v1/messages", .messages, "https://x.test/v1/messages"), + ( + "https://x.test/v1beta/models/{model}:generateContent", .generateContent, + "https://x.test/v1beta/models/{model}:generateContent" + ), + // A gateway is free to serve a shape under a prefix of its own. + ( + "https://x.test/anthropic/v1/messages", .messages, + "https://x.test/anthropic/v1/messages" + ), + ] + + for entry in cases { + let endpoint = ProviderEndpoint(url: entry.url) + #expect(endpoint.format == entry.format, "format for \(entry.url)") + #expect(endpoint.requestURL == entry.request, "request URL for \(entry.url)") + // What was typed is what the field shows again. + #expect(endpoint.url == entry.url) + } + } + + /// The three differences that are never meaningful in an endpoint name and + /// are all present in URLs people paste. + @Test + func detectionIgnoresCaseTrailingSlashesAndQueries() { + #expect(ProviderEndpoint(url: "https://x.test/v1/MESSAGES").format == .messages) + #expect(ProviderEndpoint(url: "https://x.test/v1/messages/").format == .messages) + #expect(ProviderEndpoint(url: "https://x.test/v1/messages?beta=1").format == .messages) + + // Streaming is the same endpoint with the same body, so someone who + // pasted that variant has still said which shape they meant. + #expect( + ProviderEndpoint(url: "https://x.test/v1beta/models/g-1:streamGenerateContent").format + == .generateContent + ) + } + + /// A query has to survive, and it has to stay at the end — appending a path + /// behind `?key=…` would make the key part of the path. + @Test + func aQueryStaysAtTheEndOfWhateverIsComposed() { + let endpoint = ProviderEndpoint(url: "https://x.test/v1?key=abc") + #expect(endpoint.requestURL == "https://x.test/v1/chat/completions?key=abc") + #expect(endpoint.baseURL == "https://x.test/v1?key=abc") + } + + /// An explicit pick changes the body and not the destination. + /// + /// Rewriting the address to match the picked type would overrule the person + /// who pasted it; the caption under the field says which format is going + /// there instead. + @Test + func anExplicitTypeOverridesTheURLWithoutMovingIt() { + let conflicting = ProviderEndpoint( + apiType: .messages, url: "https://x.test/v1/chat/completions" + ) + #expect(conflicting.format == .messages) + #expect(conflicting.requestURL == "https://x.test/v1/chat/completions") + + // And an explicit pick on a bare base URL supplies that format's path. + let bare = ProviderEndpoint(apiType: .generateContent, url: "https://x.test/v1beta") + #expect(bare.requestURL == "https://x.test/v1beta/models/{model}:generateContent") + } + + /// The base a listing hangs off is the typed URL with the endpoint removed. + /// + /// The `generateContent` shape is why this cannot be a fixed suffix: it + /// names the model inside the path, so cutting at the colon would leave the + /// model id behind and AgentKit would list from `…/models/gemini-x/models`. + @Test + func theListingBaseHasTheEndpointRemoved() { + let cases = [ + ("https://x.test/v1/chat/completions", "https://x.test/v1"), + ("https://x.test/v1", "https://x.test/v1"), + ("https://x.test/v1beta/models/gemini-x:generateContent", "https://x.test/v1beta"), + ("https://x.test/anthropic/v1/messages", "https://x.test/anthropic/v1"), + ] + for (typed, base) in cases { + #expect(ProviderEndpoint(url: typed).baseURL == base, "base for \(typed)") + } + #expect(ProviderEndpoint(url: " ").baseURL.isEmpty) + + // And the half AgentKit owns: `/models` appended to that base. + var provider = ModelProvider(name: "Gateway") + provider.baseURL = ProviderEndpoint(url: "https://x.test/v1/chat/completions").baseURL + #expect(provider.resolvedModelsURL == "https://x.test/v1/models") + } + + /// A provider nobody has typed an address into points nowhere, and says so + /// rather than reaching some vendor's public endpoint. + @Test + func aBlankURLComposesNothing() { + let endpoint = ProviderEndpoint() + #expect(!endpoint.isConfigured) + #expect(endpoint.requestURL.isEmpty) + #expect(endpoint.baseURL.isEmpty) + } + + /// Every pre-V12 combination read back as the endpoint it resolved to. + /// + /// The old shape had two fields and two fallbacks — a blank base URL meant + /// the vendor's public host, a blank path meant that vendor's default + /// endpoint — and both are gone. What this pins is that a provider which + /// worked before the change still points at the same address after it. + @Test + func legacyRowsFoldIntoTheAddressTheyUsedToResolveTo() { + let cases: [(kind: String, responses: Bool, format: ModelAPIFormat, url: String)] = [ + ("openAI", false, .chatCompletions, "https://api.openai.com/v1/chat/completions"), + ("openAI", true, .responses, "https://api.openai.com/v1/responses"), + ("anthropic", false, .messages, "https://api.anthropic.com/v1/messages"), + ( + "google", false, .generateContent, + "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + ), + ] + + for entry in cases { + let row = PersistentModelProvider( + ModelProvider(name: "Legacy"), endpoint: ProviderEndpoint(), sortIndex: 0 + ) + row.apiType = "" + row.kind = entry.kind + row.usesResponsesAPI = entry.responses + + #expect(row.endpoint.format == entry.format, "format for \(entry.kind)") + #expect(row.value(models: []).inferenceURL == entry.url, "URL for \(entry.kind)") + // Explicit rather than Automatic: reproducing the old behaviour + // beats re-deriving it and hoping the two agree. + #expect(row.endpoint.apiType == ModelAPIType(entry.format)) + } + } + + /// A base URL and an API path the user actually typed are kept, rather than + /// replaced by the format's default path. + @Test + func legacyRowsKeepATypedBaseAndPath() { + let row = PersistentModelProvider( + ModelProvider(name: "Legacy"), endpoint: ProviderEndpoint(), sortIndex: 0 + ) + row.apiType = "" + row.kind = "anthropic" + row.baseURL = "https://gateway.test/anthropic" + row.apiPath = "/v1/messages" + + #expect(row.endpoint.url == "https://gateway.test/anthropic/v1/messages") + #expect(row.value(models: []).inferenceURL == "https://gateway.test/anthropic/v1/messages") + #expect(row.value(models: []).apiFormat == .messages) + } + + /// 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 + func endpointsSurviveAReloadIncludingTheAutomaticChoice() throws { + let container = try ModelContainer( + for: HostPersistence.schema, + configurations: [ + ModelConfiguration( + "EndpointRoundTrip", schema: HostPersistence.schema, + isStoredInMemoryOnly: true, cloudKitDatabase: .none + ) + ] + ) + + let automatic = ModelProvider(name: "Automatic") + let explicit = ModelProvider(name: "Explicit") + + do { + let store = AIConfigurationModel( + modelContext: container.mainContext, persistsSecrets: false + ) + store.upsert(automatic) + store.upsert(explicit) + store.setEndpoint(ProviderEndpoint(url: "https://x.test/v1"), for: automatic.id) + store.setEndpoint( + ProviderEndpoint(apiType: .messages, url: "https://x.test/v1"), for: explicit.id + ) + #expect(store.storageError == nil) + } + + let reloaded = AIConfigurationModel( + modelContext: container.mainContext, persistsSecrets: false + ) + #expect(reloaded.endpoint(for: automatic.id).apiType == .automatic) + #expect(reloaded.endpoint(for: automatic.id).url == "https://x.test/v1") + #expect(reloaded.modelProvider(automatic.id)?.apiFormat == .chatCompletions) + #expect( + reloaded.modelProvider(automatic.id)?.inferenceURL == "https://x.test/v1/chat/completions" + ) + + #expect(reloaded.endpoint(for: explicit.id).apiType == .messages) + #expect(reloaded.endpoint(for: explicit.id).url == "https://x.test/v1") + #expect(reloaded.modelProvider(explicit.id)?.inferenceURL == "https://x.test/v1/messages") + } +} diff --git a/TermindTests/SettingsNavigationTests.swift b/TermindTests/SettingsNavigationTests.swift index 1ce67ee8..9a0061b7 100644 --- a/TermindTests/SettingsNavigationTests.swift +++ b/TermindTests/SettingsNavigationTests.swift @@ -133,7 +133,7 @@ struct SettingsNavigationTests { @Test func configuredProviderDraftIsKept() { var provider = ModelProvider(name: String(localized: "New Provider")) - provider.baseURL = "https://example.com/v1" + provider.inferenceURL = "https://example.com/v1/chat/completions" #expect(!SettingsDraftPolicy.shouldDiscard(provider)) provider = ModelProvider(name: "Work") diff --git a/TermindTests/WebSearchTests.swift b/TermindTests/WebSearchTests.swift index 77edbaa4..52178836 100644 --- a/TermindTests/WebSearchTests.swift +++ b/TermindTests/WebSearchTests.swift @@ -39,15 +39,15 @@ struct WebSearchTests { func protocolSupportIsSeparateFromTheModelGuess() { var provider = ModelProvider(name: "Gateway") #expect(!provider.acceptsNativeWebSearchTool) - provider.usesResponsesAPI = true + provider.apiFormat = .responses #expect(provider.acceptsNativeWebSearchTool) var anthropic = ModelProvider(name: "Anthropic") - anthropic.kind = .anthropic + anthropic.apiFormat = .messages #expect(anthropic.acceptsNativeWebSearchTool) var google = ModelProvider(name: "Google") - google.kind = .google + google.apiFormat = .generateContent #expect(google.acceptsNativeWebSearchTool) } @@ -56,7 +56,7 @@ struct WebSearchTests { var model = AIModel(id: "gpt-5.4") model.abilities = [.toolCall, .webSearch] var provider = ModelProvider(name: "OpenAI") - provider.usesResponsesAPI = true + provider.apiFormat = .responses #expect(provider.supportsNativeWebSearch(model: model)) }