Files
Termind/FrameworkTests/ProviderEndpointTests.swift

180 lines
8.0 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import SwiftData
import TermindDomain
import TermindPersistence
import Testing
/// 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)
}
}