M7 #5 cross-platform: validate model ID against provider catalog

Pass-1 demonstrated the bug end-to-end: user saved provider nous
+ model claude-haiku-4-5-20251001 (an Anthropic name Nous Portal
doesn't serve). Scarf accepted the save, wrote config.yaml, and
Hermes surfaced the failure six hours later as HTTP 404. Catch at
save time.

New ModelCatalogService.validateModel(_:for:) returns one of:

- .valid — model is in the provider's catalog, or the provider is
  overlay-only (Nous Portal / OpenAI Codex / Qwen OAuth etc. — those
  don't mirror to models.dev, so any non-empty string is
  provisionally accepted; runtime errors still surface via the chat
  error banner from M7 #2).
- .unknownProvider(providerID:) — no catalog entry at all; save
  with an advisory. Usually means offline / missing local cache.
- .invalid(providerName:suggestions:) — block the save, offer up to
  5 close-by models as "did you mean…". Prefix-match on first 3
  chars; falls through to newest-5 when no prefix hits.

Mac ModelPickerSheet.submitSelection now routes through the
validator before onSelect. On .invalid it raises a .alert(item:)
with the suggestion list; user picks "Pick from catalog" (drops
out of custom mode) or "Edit" (keep the typed value to fix).

5 unit tests cover the happy path, unknown-provider branch, overlay-
only bypass, invalid-with-suggestions (using the exact pass-1 pair),
and empty input.

ScarfGo's scoped-settings editor (Phase 4.3) will reuse the same
validator when it lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 13:34:37 +02:00
parent 42c0f683bd
commit e1f862e2f9
4 changed files with 252 additions and 6 deletions
@@ -285,6 +285,75 @@ public struct ModelCatalogService: Sendable {
)
}
/// Result of validating a user-entered model ID against the
/// selected provider. See `validateModel(_:for:)`.
public enum ModelValidation: Equatable, Sendable {
/// Accept the save the model is in the provider's catalog
/// (or the provider is overlay-only, where a free-form model
/// name is the normal path).
case valid
/// Accept with a warning we don't have a catalog entry for
/// the provider at all, so can't check. Usually means the
/// user is offline or the local cache is missing. Save but
/// surface an advisory.
case unknownProvider(providerID: String)
/// Block the save the provider exists but doesn't serve
/// that model. Includes a handful of close-by suggestions
/// for the UI to render as "did you mean".
case invalid(providerName: String, suggestions: [String])
}
/// Validate `modelID` against `providerID` before persisting it as
/// `model.default` in `config.yaml`. Centralises the logic so both
/// Mac's ModelPickerSheet and ScarfGo's scoped settings editor
/// (Phase 4.3) use the same check. Pass-1 found that you could
/// save `claude-haiku-4-5-20251001` under provider `nous`
/// Nous's catalog has no such model and Hermes later failed with
/// HTTP 404 at runtime. Catch that at save time, not 6 hours later.
public func validateModel(_ modelID: String, for providerID: String) -> ModelValidation {
let trimmed = modelID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return .invalid(providerName: providerID, suggestions: [])
}
// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen
// OAuth, ) serve their own catalogs that aren't mirrored to
// models.dev, so we don't have a reliable way to check model
// IDs locally. Treat any non-empty value as provisionally
// valid the worst case is the runtime 404 we hit in pass-1,
// but the UI has the error banner now (M7 #2) to surface that
// cleanly.
//
// Exception: if an overlay-only provider DOES appear in the
// models.dev cache (unlikely but possible as catalogs evolve),
// we fall through to the real check below.
let models = loadModels(for: providerID)
if models.isEmpty {
if Self.overlayOnlyProviders[providerID] != nil {
return .valid
}
return .unknownProvider(providerID: providerID)
}
if models.contains(where: { $0.modelID == trimmed }) {
return .valid
}
// No exact match offer the closest names (by prefix) as
// suggestions. Up to 5, ordered by release date (newest
// first already the sort order of loadModels).
let lowerTrimmed = trimmed.lowercased()
let byPrefix = models
.filter { $0.modelID.lowercased().hasPrefix(String(lowerTrimmed.prefix(3))) }
.prefix(5)
.map(\.modelID)
let suggestions = byPrefix.isEmpty
? Array(models.prefix(5).map(\.modelID))
: Array(byPrefix)
let providerName = providerByID(providerID)?.providerName ?? providerID
return .invalid(providerName: providerName, suggestions: suggestions)
}
// MARK: - Decoding
private func loadCatalog() -> [String: ProviderEntry]? {
@@ -207,6 +207,94 @@ import Foundation
#expect(bad.loadProviders().isEmpty)
}
@Test func validateModelAcceptsCatalogHit() throws {
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: tmp) }
let json = #"""
{
"deepseek": {
"id": "deepseek",
"name": "DeepSeek",
"models": {
"deepseek-v4-flash": {"name": "DeepSeek v4 Flash"},
"deepseek-v4-pro": {"name": "DeepSeek v4 Pro"}
}
}
}
"""#
try json.write(to: tmp, atomically: true, encoding: .utf8)
let svc = ModelCatalogService(path: tmp.path)
#expect(svc.validateModel("deepseek-v4-flash", for: "deepseek") == .valid)
}
@Test func validateModelRejectsMissingModelWithSuggestions() throws {
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: tmp) }
let json = #"""
{
"deepseek": {
"id": "deepseek",
"name": "DeepSeek",
"models": {
"deepseek-v4-flash": {"name": "DeepSeek v4 Flash"},
"deepseek-v4-pro": {"name": "DeepSeek v4 Pro"}
}
}
}
"""#
try json.write(to: tmp, atomically: true, encoding: .utf8)
let svc = ModelCatalogService(path: tmp.path)
// The exact bug from pass-1: Anthropic-style model ID under
// deepseek provider.
let result = svc.validateModel("claude-haiku-4-5-20251001", for: "deepseek")
guard case let .invalid(providerName, suggestions) = result else {
Issue.record("Expected .invalid, got \(result)")
return
}
#expect(providerName == "DeepSeek")
// No prefix match on "cla" so we fall through to the first-5
// suggestion fallback; both entries are present.
#expect(suggestions.count == 2)
}
@Test func validateModelReportsUnknownProviderWhenNoCatalogEntry() throws {
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: tmp) }
// Empty catalog — provider "openai" won't be found + isn't
// overlay-only, so should return .unknownProvider.
try "{}".write(to: tmp, atomically: true, encoding: .utf8)
let svc = ModelCatalogService(path: tmp.path)
let result = svc.validateModel("gpt-5", for: "openai")
if case .unknownProvider(let pid) = result {
#expect(pid == "openai")
} else {
Issue.record("Expected .unknownProvider, got \(result)")
}
}
@Test func validateModelAcceptsOverlayProvidersWithoutCatalog() throws {
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: tmp) }
try "{}".write(to: tmp, atomically: true, encoding: .utf8)
let svc = ModelCatalogService(path: tmp.path)
// Nous is an overlay-only provider — any non-empty string is
// accepted because the overlay has no local catalog mirror.
#expect(svc.validateModel("deepseek/deepseek-v4-flash", for: "nous") == .valid)
}
@Test func validateModelRejectsEmptyInput() throws {
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: tmp) }
try "{}".write(to: tmp, atomically: true, encoding: .utf8)
let svc = ModelCatalogService(path: tmp.path)
let result = svc.validateModel("", for: "nous")
if case .invalid = result {
// expected
} else {
Issue.record("Expected .invalid for empty input, got \(result)")
}
}
// MARK: - ProjectDashboardService
@Test func projectDashboardServiceRegistryRoundTrip() throws {