mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
da721fa276
Adds the five v0.12 inference providers to ModelCatalogService.overlayOnlyProviders so the model picker reaches them. IDs match HERMES_OVERLAYS verbatim: - gmi → GMI Cloud (api_key) - azure-foundry → Azure AI Foundry (api_key) - lmstudio → LM Studio (api_key, promoted from custom-endpoint alias) - minimax-oauth → MiniMax (OAuth, oauth_external) - tencent-tokenhub → Tencent TokenHub (api_key) Auxiliary tasks: drop the `flush_memories` row (Hermes removed it entirely in v0.12) and add `auxiliary.curator` so users can configure the model the autonomous curator's review fork uses. The Curator row is gated on HermesCapabilities.hasCuratorAux, so v0.11 hosts don't see a control that writes a key Hermes ignores. AuxiliarySettings, the YAML parser, and HealthViewModel's Tool Gateway breakdown are all updated. Side fixes: - CredentialPoolsGatingTests was missing `import ScarfCore` after ModelCatalogService moved to the package (broke the test target's compile against pure-Mac scarf). - Promoted `ModelCatalogService.overlayOnlyProviders` to public so the new `v012OverlayProvidersCarryCorrectAuthTypes` lock-in test can reach it. Tests: 14 ToolGateway tests pass; 209 ScarfCore tests pass; both Mac and iOS schemes build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
227 lines
8.9 KiB
Swift
227 lines
8.9 KiB
Swift
import Testing
|
|
import Foundation
|
|
import ScarfCore
|
|
@testable import scarf
|
|
|
|
/// Invariants around Hermes v0.10.0 Tool Gateway integration:
|
|
/// overlay-provider merge, Nous Portal subscription detection, and
|
|
/// `platform_toolsets` YAML parsing.
|
|
@Suite struct ToolGatewayTests {
|
|
|
|
// MARK: - Fixtures
|
|
|
|
/// Minimal models.dev cache with exactly two providers so the overlay
|
|
/// merge is easy to reason about — none of them are overlays.
|
|
private func writeCacheFixture() throws -> String {
|
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
let path = dir.appendingPathComponent("models_dev_cache.json").path
|
|
let json = """
|
|
{
|
|
"anthropic": {
|
|
"name": "Anthropic",
|
|
"models": {
|
|
"claude-sonnet-4-5-20250929": { "name": "Claude Sonnet 4.5" }
|
|
}
|
|
},
|
|
"openai": {
|
|
"name": "OpenAI",
|
|
"models": {
|
|
"gpt-4o": { "name": "GPT-4o" }
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
try json.write(toFile: path, atomically: true, encoding: .utf8)
|
|
return path
|
|
}
|
|
|
|
private func writeAuthFixture(_ body: String) throws -> String {
|
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-auth-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
let path = dir.appendingPathComponent("auth.json").path
|
|
try body.write(toFile: path, atomically: true, encoding: .utf8)
|
|
return path
|
|
}
|
|
|
|
// MARK: - ModelCatalogService overlay merge
|
|
|
|
@Test func overlayOnlyProvidersAppearInPicker() throws {
|
|
let path = try writeCacheFixture()
|
|
let service = ModelCatalogService(path: path)
|
|
let providers = service.loadProviders()
|
|
|
|
let ids = providers.map(\.providerID)
|
|
#expect(ids.contains("nous"), "Nous Portal must appear after overlay merge")
|
|
#expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear")
|
|
#expect(ids.contains("qwen-oauth"), "Qwen OAuth overlay must appear")
|
|
// v0.12 additions — IDs must match HERMES_OVERLAYS in
|
|
// hermes-agent/hermes_cli/providers.py exactly. Drift here
|
|
// means the picker can't reach the new providers.
|
|
#expect(ids.contains("gmi"), "GMI Cloud overlay must appear (v0.12)")
|
|
#expect(ids.contains("azure-foundry"), "Azure AI Foundry overlay must appear (v0.12)")
|
|
#expect(ids.contains("lmstudio"), "LM Studio overlay must appear (v0.12)")
|
|
#expect(ids.contains("minimax-oauth"), "MiniMax OAuth overlay must appear (v0.12)")
|
|
#expect(ids.contains("tencent-tokenhub"), "Tencent TokenHub overlay must appear (v0.12)")
|
|
// Cached providers still present.
|
|
#expect(ids.contains("anthropic"))
|
|
#expect(ids.contains("openai"))
|
|
}
|
|
|
|
@Test func v012OverlayProvidersCarryCorrectAuthTypes() throws {
|
|
// The auth-type drives whether Settings shows an API-key field,
|
|
// an OAuth flow, or external-process wiring. Locking the v0.12
|
|
// additions here so a typo doesn't quietly land users in the
|
|
// wrong setup flow.
|
|
let overlays = ModelCatalogService.overlayOnlyProviders
|
|
#expect(overlays["gmi"]?.authType == .apiKey)
|
|
#expect(overlays["azure-foundry"]?.authType == .apiKey)
|
|
#expect(overlays["lmstudio"]?.authType == .apiKey)
|
|
#expect(overlays["minimax-oauth"]?.authType == .oauthExternal)
|
|
#expect(overlays["tencent-tokenhub"]?.authType == .apiKey)
|
|
// None of the v0.12 additions are subscription-gated (only Nous
|
|
// Portal is).
|
|
for id in ["gmi", "azure-foundry", "lmstudio", "minimax-oauth", "tencent-tokenhub"] {
|
|
#expect(overlays[id]?.subscriptionGated == false, "\(id) shouldn't be subscription-gated")
|
|
}
|
|
}
|
|
|
|
@Test func nousPortalSortsFirst() throws {
|
|
let path = try writeCacheFixture()
|
|
let service = ModelCatalogService(path: path)
|
|
let providers = service.loadProviders()
|
|
#expect(providers.first?.providerID == "nous",
|
|
"Subscription-gated providers must sort before the alphabetical block")
|
|
}
|
|
|
|
@Test func overlayProvidersCarryMetadata() throws {
|
|
let path = try writeCacheFixture()
|
|
let service = ModelCatalogService(path: path)
|
|
let providers = service.loadProviders()
|
|
|
|
let nous = providers.first { $0.providerID == "nous" }
|
|
#expect(nous?.isOverlay == true)
|
|
#expect(nous?.subscriptionGated == true)
|
|
#expect(nous?.providerName == "Nous Portal")
|
|
#expect(nous?.modelCount == 0, "Overlay-only providers have no models in the cache")
|
|
|
|
let codex = providers.first { $0.providerID == "openai-codex" }
|
|
#expect(codex?.isOverlay == true)
|
|
#expect(codex?.subscriptionGated == false,
|
|
"Only Nous is subscription-gated today")
|
|
}
|
|
|
|
@Test func cachedProvidersAreNotMarkedOverlay() throws {
|
|
let path = try writeCacheFixture()
|
|
let service = ModelCatalogService(path: path)
|
|
let providers = service.loadProviders()
|
|
|
|
let anthropic = providers.first { $0.providerID == "anthropic" }
|
|
#expect(anthropic?.isOverlay == false)
|
|
#expect(anthropic?.subscriptionGated == false)
|
|
}
|
|
|
|
@Test func providerByIDReturnsOverlayWhenCacheMisses() throws {
|
|
let path = try writeCacheFixture()
|
|
let service = ModelCatalogService(path: path)
|
|
|
|
let nous = service.providerByID("nous")
|
|
#expect(nous?.providerName == "Nous Portal")
|
|
#expect(nous?.isOverlay == true)
|
|
|
|
let missing = service.providerByID("definitely-not-a-provider")
|
|
#expect(missing == nil)
|
|
}
|
|
|
|
// MARK: - NousSubscriptionService
|
|
|
|
@Test func subscriptionAbsentWhenAuthFileMissing() throws {
|
|
let path = "/tmp/this-file-should-not-exist-\(UUID().uuidString).json"
|
|
let service = NousSubscriptionService(path: path)
|
|
let state = service.loadState()
|
|
#expect(state == .absent)
|
|
}
|
|
|
|
@Test func subscriptionAbsentWhenProvidersEmpty() throws {
|
|
let path = try writeAuthFixture("""
|
|
{ "version": 1, "providers": {}, "active_provider": null }
|
|
""")
|
|
let state = NousSubscriptionService(path: path).loadState()
|
|
#expect(state.present == false)
|
|
#expect(state.subscribed == false)
|
|
}
|
|
|
|
@Test func subscriptionPresentButInactiveWhenOtherProviderActive() throws {
|
|
let path = try writeAuthFixture("""
|
|
{
|
|
"version": 1,
|
|
"providers": { "nous": { "access_token": "tok-12345" } },
|
|
"active_provider": "anthropic"
|
|
}
|
|
""")
|
|
let state = NousSubscriptionService(path: path).loadState()
|
|
#expect(state.present == true)
|
|
#expect(state.providerIsNous == false)
|
|
#expect(state.subscribed == false,
|
|
"Auth alone isn't enough — the Tool Gateway only routes when Nous is the active provider")
|
|
}
|
|
|
|
@Test func subscriptionActiveWhenAuthAndActiveProviderLineUp() throws {
|
|
let path = try writeAuthFixture("""
|
|
{
|
|
"version": 1,
|
|
"providers": { "nous": { "access_token": "tok-12345" } },
|
|
"active_provider": "nous"
|
|
}
|
|
""")
|
|
let state = NousSubscriptionService(path: path).loadState()
|
|
#expect(state.present == true)
|
|
#expect(state.providerIsNous == true)
|
|
#expect(state.subscribed == true)
|
|
}
|
|
|
|
@Test func subscriptionAbsentWhenTokenEmpty() throws {
|
|
let path = try writeAuthFixture("""
|
|
{
|
|
"version": 1,
|
|
"providers": { "nous": { "access_token": "" } },
|
|
"active_provider": "nous"
|
|
}
|
|
""")
|
|
let state = NousSubscriptionService(path: path).loadState()
|
|
#expect(state.present == false,
|
|
"Empty token is as good as no token — don't claim subscription")
|
|
}
|
|
|
|
@Test func subscriptionAbsentOnMalformedJSON() throws {
|
|
let path = try writeAuthFixture("{ this is not valid json")
|
|
let state = NousSubscriptionService(path: path).loadState()
|
|
#expect(state == .absent)
|
|
}
|
|
|
|
// MARK: - platform_toolsets YAML parse
|
|
|
|
@Test func platformToolsetsParsed() throws {
|
|
let yaml = """
|
|
model:
|
|
default: claude-sonnet-4.5
|
|
provider: anthropic
|
|
platform_toolsets:
|
|
cli:
|
|
- browser
|
|
- messaging
|
|
slack:
|
|
- messaging
|
|
"""
|
|
let parsed = HermesFileService.parseNestedYAML(yaml)
|
|
#expect(parsed.lists["platform_toolsets.cli"] == ["browser", "messaging"])
|
|
#expect(parsed.lists["platform_toolsets.slack"] == ["messaging"])
|
|
}
|
|
|
|
@Test func platformToolsetsEmptyWhenMissing() throws {
|
|
// HermesConfig.empty should have no platform toolsets.
|
|
let config = HermesConfig.empty
|
|
#expect(config.platformToolsets.isEmpty)
|
|
}
|
|
}
|