Skip to content
Merged
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
38 changes: 30 additions & 8 deletions Sources/Tachikoma/Models/ModelSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ public struct ModelSelector {
if provider == "openrouter" {
return .openRouter(modelId: qualified.model)
}
if provider == "ollama" {
// Ollama is an open registry: pass unknown names through as custom
// model ids instead of gating on the known-model list.
return .ollama(self
.parseOllamaModel(qualified.model.lowercased()) ?? .custom(qualified.model.lowercased()))
}
if !["ollama", "lmstudio", "lm-studio"].contains(provider) {
return .openRouter(modelId: normalized)
}
Expand Down Expand Up @@ -436,17 +442,23 @@ public struct ModelSelector {
switch normalizedProvider {
case "openai":
return Model.OpenAI.allCases.compactMap {
if case .custom = $0 { return nil }
if case .custom = $0 {
return nil
}
return $0.modelId
}
case "anthropic", "claude":
return Model.Anthropic.allCases.compactMap {
if case .custom = $0 { return nil }
if case .custom = $0 {
return nil
}
return $0.modelId
}
case "grok", "xai":
return Model.Grok.allCases.compactMap {
if case .custom = $0 { return nil }
if case .custom = $0 {
return nil
}
return $0.modelId
}
case "google", "gemini":
Expand All @@ -457,12 +469,16 @@ public struct ModelSelector {
return Model.Kimi.allCases.map(\.modelId)
case "ollama":
return Model.Ollama.allCases.compactMap {
if case .custom = $0 { return nil }
if case .custom = $0 {
return nil
}
return $0.modelId
}
case "lmstudio", "lm-studio":
return Model.LMStudio.allCases.compactMap {
if case .custom = $0 { return nil }
if case .custom = $0 {
return nil
}
return $0.modelId
}
default:
Expand Down Expand Up @@ -496,9 +512,15 @@ public struct ModelCapabilityInfo {
/// Format capabilities for CLI display
public var description: String {
var capabilities: [String] = []
if self.supportsVision { capabilities.append("vision") }
if self.supportsTools { capabilities.append("tools") }
if self.supportsStreaming { capabilities.append("streaming") }
if self.supportsVision {
capabilities.append("vision")
}
if self.supportsTools {
capabilities.append("tools")
}
if self.supportsStreaming {
capabilities.append("streaming")
}

let capabilityString = capabilities.isEmpty ? "basic" : capabilities.joined(separator: ", ")
return "\(self.provider)/\(self.modelId) (\(capabilityString))"
Expand Down
50 changes: 26 additions & 24 deletions Tests/TachikomaTests/Core/MinimalModernAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -789,34 +789,36 @@ private struct AgentRefusalTests {

private func withRegisteredCustomProvider(
_ configJSON: String,
operation: () async throws -> Void,
operation: @Sendable () async throws -> Void,
) async throws {
let originalProfile = TachikomaConfiguration.profileDirectoryName
let tempProfile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let emptyProfile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)

try FileManager.default.createDirectory(at: tempProfile, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: emptyProfile, withIntermediateDirectories: true)
try configJSON.write(to: tempProfile.appendingPathComponent("config.json"), atomically: true, encoding: .utf8)
try #"{"customProviders":{}}"#.write(
to: emptyProfile.appendingPathComponent("config.json"),
atomically: true,
encoding: .utf8,
)
try await TestEnvironmentMutex.shared.withLock {
let originalProfile = TachikomaConfiguration.profileDirectoryName
let tempProfile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let emptyProfile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)

try FileManager.default.createDirectory(at: tempProfile, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: emptyProfile, withIntermediateDirectories: true)
try configJSON.write(
to: tempProfile.appendingPathComponent("config.json"),
atomically: true,
encoding: .utf8,
)
try #"{"customProviders":{}}"#.write(
to: emptyProfile.appendingPathComponent("config.json"),
atomically: true,
encoding: .utf8,
)

TachikomaConfiguration.profileDirectoryName = tempProfile.path
CustomProviderRegistry.shared.loadFromProfile()
TachikomaConfiguration.profileDirectoryName = tempProfile.path
CustomProviderRegistry.shared.loadFromProfile()

defer {
TachikomaConfiguration.profileDirectoryName = emptyProfile.path
CustomProviderRegistry.shared.loadFromProfile()
TachikomaConfiguration.profileDirectoryName = originalProfile
}

do {
try await operation()
TachikomaConfiguration.profileDirectoryName = emptyProfile.path
CustomProviderRegistry.shared.loadFromProfile()
TachikomaConfiguration.profileDirectoryName = originalProfile
} catch {
TachikomaConfiguration.profileDirectoryName = emptyProfile.path
CustomProviderRegistry.shared.loadFromProfile()
TachikomaConfiguration.profileDirectoryName = originalProfile
throw error
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions Tests/TachikomaTests/Core/ModelParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ struct ModelParsingTests {
#expect(parsed?.modelId == "qwen2.5vl:3b")
}

@Test
func `ModelSelector strips the ollama prefix from provider-qualified custom models`() throws {
// Regression: parseModel fell through to the bare-name fallback with the
// prefix still attached, producing .custom("ollama/qwen2.5vl:latest") —
// a model id that does not exist on the Ollama server.
#expect(try ModelSelector.parseModel("ollama/qwen2.5vl:latest") == .ollama(.custom("qwen2.5vl:latest")))
#expect(try ModelSelector
.parseModel("ollama/some-future-model:tag") == .ollama(.custom("some-future-model:tag")))
// Known shortcut names keep resolving to their typed cases.
#expect(try ModelSelector.parseModel("ollama/llama3.3") == .ollama(.llama33))
}

@Test
func `parse local provider shortcuts`() {
#expect(LanguageModel.parse(from: "ollama") == .ollama(.llama33))
Expand Down
Loading