Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion Packages/OsaurusCore/Managers/Model/ModelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,27 @@ final class ModelManager: NSObject, ObservableObject {

// Pull the OsaurusAI HF org listing once on launch so newly published
// models surface in the Recommended tab without requiring a code push.
Task { [weak self] in await self?.loadOsaurusAIOrgModels() }
//
// The unit-test runner constructs `ModelManager()` repeatedly to drive
// `applyOsaurusOrgFetch` directly. If the launch-time HF fetch races
// with those test calls, whichever finishes last wins and the merge
// result is non-deterministic — that's the regression class behind
// `ModelManagerSuggestedTests/applyOsaurusOrgFetch_*` flaking in CI.
// Skip the background fetch under XCTest; production launches still
// get it because `XCTestConfigurationFilePath` is only set inside
// a test host.
if !Self.isRunningInTestEnvironment {
Task { [weak self] in await self?.loadOsaurusAIOrgModels() }
}
}

/// True when the current process was launched by xctest. Used to gate
/// network-touching launch-time side effects so tests can drive the
/// affected code paths deterministically.
nonisolated private static var isRunningInTestEnvironment: Bool {
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
|| ProcessInfo.processInfo.environment["XCTestBundlePath"] != nil
|| ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil
}

// MARK: - Public Methods
Expand Down
4 changes: 4 additions & 0 deletions Packages/OsaurusCore/Services/Chat/ChatEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ actor ChatEngine: Sendable, ChatEngineProtocol {
maxTokens: maxTok
)

case .unavailable(let requested):
throw EngineError(kind: .noServiceAvailable(requested: requested))
case .none:
throw EngineError(kind: .modelNotFound(requested: request.model))
}
Expand Down Expand Up @@ -486,6 +488,8 @@ actor ChatEngine: Sendable, ChatEngineProtocol {
usage: usage,
system_fingerprint: nil
)
case .unavailable(let requested):
throw EngineError(kind: .noServiceAvailable(requested: requested))
case .none:
throw EngineError(kind: .modelNotFound(requested: request.model))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,11 @@ public actor CoreModelService {
parameters: params,
requestedModel: model
)
case .none:
case .unavailable, .none:
// CoreModelError doesn't currently distinguish "unavailable" vs
// "unknown" — both surface as `modelUnavailable`. Public API
// consumers go through ChatEngine, which does have the
// distinction (.modelNotFound vs .noServiceAvailable).
throw CoreModelError.modelUnavailable(model)
}
}
Expand Down
40 changes: 34 additions & 6 deletions Packages/OsaurusCore/Services/Inference/ModelService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,16 @@ protocol ToolCapableService: ModelService {

/// Simple router that selects a service based on the request and environment.
enum ModelRoute {
/// A service is available and will handle this request.
case service(service: ModelService, effectiveModel: String)
/// A service is configured to handle this model id but reports
/// `isAvailable() == false` (e.g. foundation models on hardware that
/// doesn't support them, or a backend temporarily unhealthy). API layers
/// should respond with 503, not 404, so clients know to retry instead of
/// reinstalling.
case unavailable(requestedModel: String)
/// Nothing in the candidate set claims to handle this model id at all.
/// API layers should respond with 404.
case none
}

Expand All @@ -212,38 +221,57 @@ struct ModelServiceRouter {
/// - requestedModel: Model string requested by client. "default" or empty means system default.
/// - services: Candidate services to consider (default includes FoundationModels service when present).
/// - remoteServices: Optional array of remote provider services to also consider.
///
/// Note on remote providers: callers are expected to pass the *connected*
/// remote services list. A configured-but-disconnected remote provider
/// is not visible to this router, so its absence is reported as `.none`.
/// Distinguishing "remote provider exists but is offline" requires
/// `RemoteProviderManager` to track configured-but-disconnected services,
/// which is a separate change.
static func resolve(
requestedModel: String?,
services: [ModelService],
remoteServices: [ModelService] = []
) -> ModelRoute {
let trimmed = requestedModel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let isDefault = trimmed.isEmpty || trimmed.caseInsensitiveCompare("default") == .orderedSame
var sawHandlerButUnavailable = false

// First, check remote provider services (they use prefixed model names like "openai/gpt-4")
// These take priority for explicit model requests with provider prefixes
if !isDefault {
for svc in remoteServices {
guard svc.isAvailable() else { continue }
if svc.handles(requestedModel: trimmed) {
return .service(service: svc, effectiveModel: trimmed)
if svc.isAvailable() {
return .service(service: svc, effectiveModel: trimmed)
}
sawHandlerButUnavailable = true
}
}
}

// Then check local services
for svc in services {
guard svc.isAvailable() else { continue }
// Route default to a service that handles it
if isDefault && svc.handles(requestedModel: requestedModel) {
return .service(service: svc, effectiveModel: "foundation")
if svc.isAvailable() {
return .service(service: svc, effectiveModel: "foundation")
}
sawHandlerButUnavailable = true
continue
}
// Allow explicit "foundation" (or other service-specific id) to select the service
if svc.handles(requestedModel: trimmed), !isDefault {
return .service(service: svc, effectiveModel: trimmed)
if !isDefault && svc.handles(requestedModel: trimmed) {
if svc.isAvailable() {
return .service(service: svc, effectiveModel: trimmed)
}
sawHandlerButUnavailable = true
}
}

if sawHandlerButUnavailable {
return .unavailable(requestedModel: trimmed.isEmpty ? "default" : trimmed)
}
return .none
}
}
159 changes: 159 additions & 0 deletions Packages/OsaurusCore/Tests/Model/ModelServiceRouterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//
// ModelServiceRouterTests.swift
// osaurusTests
//
// Verifies that `ModelServiceRouter.resolve` distinguishes between three
// routing outcomes:
// 1. .service — at least one service handles the model and is
// currently available.
// 2. .unavailable — at least one service claims to handle the model
// (via `handles(requestedModel:)`) but every such
// service reports `!isAvailable()`. API layers map
// this to HTTP 503 so clients retry instead of
// reinstalling the model.
// 3. .none — no service claims to handle the model at all.
// API layers map this to HTTP 404.
//

import Foundation
import Testing

@testable import OsaurusCore

private final class StubModelService: ModelService, @unchecked Sendable {
let id: String
private let availability: Bool
private let handler: @Sendable (String?) -> Bool

init(
id: String,
isAvailable: Bool = true,
handles: @escaping @Sendable (String?) -> Bool
) {
self.id = id
self.availability = isAvailable
self.handler = handles
}

func isAvailable() -> Bool { availability }
func handles(requestedModel: String?) -> Bool { handler(requestedModel) }

func generateOneShot(
messages: [ChatMessage],
parameters: GenerationParameters,
requestedModel: String?
) async throws -> String {
return ""
}

func streamDeltas(
messages: [ChatMessage],
parameters: GenerationParameters,
requestedModel: String?,
stopSequences: [String]
) async throws -> AsyncThrowingStream<String, Error> {
return AsyncThrowingStream { $0.finish() }
}
}

private func isService(_ route: ModelRoute) -> (id: String, effective: String)? {
if case .service(let svc, let effective) = route {
return (svc.id, effective)
}
return nil
}

private func isUnavailable(_ route: ModelRoute) -> String? {
if case .unavailable(let requested) = route { return requested }
return nil
}

private func isNone(_ route: ModelRoute) -> Bool {
if case .none = route { return true }
return false
}

struct ModelServiceRouterTests {

@Test func resolveReturnsServiceWhenAvailable() {
let svc = StubModelService(id: "local", isAvailable: true) { model in
model == "qwen-3b"
}
let route = ModelServiceRouter.resolve(
requestedModel: "qwen-3b",
services: [svc]
)
let picked = isService(route)
#expect(picked != nil)
#expect(picked?.effective == "qwen-3b")
}

@Test func resolveReturnsNoneWhenNoServiceClaimsModel() {
let svc = StubModelService(id: "local", isAvailable: true) { _ in false }
let route = ModelServiceRouter.resolve(
requestedModel: "unknown-model",
services: [svc]
)
#expect(isNone(route))
}

@Test func resolveReturnsUnavailableWhenHandlerExistsButOffline() {
// A service that claims to handle the model but reports unavailable
// (e.g. Foundation Models on a Mac that doesn't support them, or
// a remote provider that lost its connection between resolves).
let svc = StubModelService(id: "foundation", isAvailable: false) { model in
model == "foundation" || model == nil || model == "" || model == "default"
}
let route = ModelServiceRouter.resolve(
requestedModel: "foundation",
services: [svc]
)
#expect(isUnavailable(route) == "foundation")
}

@Test func resolvePrefersAvailableServiceOverUnavailableOne() {
let offlineSvc = StubModelService(id: "offline", isAvailable: false) { $0 == "qwen" }
let onlineSvc = StubModelService(id: "online", isAvailable: true) { $0 == "qwen" }
let route = ModelServiceRouter.resolve(
requestedModel: "qwen",
services: [offlineSvc, onlineSvc]
)
#expect(isService(route)?.id == "online")
}

@Test func resolveUnavailableOnRemoteOnly() {
let remote = StubModelService(id: "openai", isAvailable: false) { model in
model?.hasPrefix("openai/") == true
}
let route = ModelServiceRouter.resolve(
requestedModel: "openai/gpt-4",
services: [],
remoteServices: [remote]
)
#expect(isUnavailable(route) == "openai/gpt-4")
}

@Test func resolveDefaultModelRoutesToHandlingService() {
let svc = StubModelService(id: "foundation", isAvailable: true) { model in
model == nil || model == "" || model == "default"
}
let route = ModelServiceRouter.resolve(
requestedModel: nil,
services: [svc]
)
#expect(isService(route)?.effective == "foundation")
}

@Test func resolveDefaultUnavailableReturnsUnavailable() {
let svc = StubModelService(id: "foundation", isAvailable: false) { model in
model == nil || model == "" || model == "default"
}
let route = ModelServiceRouter.resolve(
requestedModel: "",
services: [svc]
)
// Empty/default requests surface as "default" in the error so the
// user-facing message is still readable.
#expect(isUnavailable(route) == "default")
}
}
Loading