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)) }