Tachikoma/Sources/Core/ModelInterface.swift
Peter Steinberger 05ca191968 Complete Tachikoma Swift Package with logo header
- Add complete Swift package implementation from vendored version
- All providers: OpenAI, Anthropic, Grok, Ollama with type separation
- Swift 6 strict concurrency support
- Comprehensive test suite
- Complete documentation (README, ARCHITECTURE, CHANGELOG)
- Add centered logo header to README for GitHub presentation
- Unified API for AI model integration
- Tool calling, streaming, and multimodal support
2025-08-02 22:49:53 +02:00

424 lines
14 KiB
Swift

import Foundation
// MARK: - Model Interface Protocol
/// Protocol defining the interface for AI model providers
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
public protocol ModelInterface: Sendable {
/// Get a non-streaming response from the model
/// - Parameter request: The model request containing messages, tools, and settings
/// - Returns: The model response
func getResponse(request: ModelRequest) async throws -> ModelResponse
/// Get a streaming response from the model
/// - Parameter request: The model request containing messages, tools, and settings
/// - Returns: An async stream of events
func getStreamedResponse(request: ModelRequest) async throws -> AsyncThrowingStream<StreamEvent, any Error>
/// Get a masked version of the API key for debugging
/// Returns the first 6 and last 2 characters of the API key
/// - Returns: Masked API key string (e.g., "sk-ant...AA")
var maskedApiKey: String { get }
}
// MARK: - Model Request & Response Types
/// Request to send to a model
public struct ModelRequest: Codable, Sendable {
/// The messages to send to the model
public let messages: [Message]
/// Available tools for the model to use
public let tools: [ToolDefinition]?
/// Model-specific settings
public let settings: ModelSettings
/// System instructions (some models support this separately from messages)
public let systemInstructions: String?
public init(
messages: [Message],
tools: [ToolDefinition]? = nil,
settings: ModelSettings,
systemInstructions: String? = nil)
{
self.messages = messages
self.tools = tools
self.settings = settings
self.systemInstructions = systemInstructions
}
}
/// Response from a model
public struct ModelResponse: Codable, Sendable {
/// Unique identifier for the response
public let id: String
/// The model that generated the response
public let model: String?
/// Content returned by the model
public let content: [AssistantContent]
/// Token usage statistics
public let usage: Usage?
/// Whether the response was flagged for safety
public let flagged: Bool
/// Reason for flagging if applicable
public let flaggedCategories: [String]?
/// Finish reason
public let finishReason: FinishReason?
public init(
id: String,
model: String? = nil,
content: [AssistantContent],
usage: Usage? = nil,
flagged: Bool = false,
flaggedCategories: [String]? = nil,
finishReason: FinishReason? = nil)
{
self.id = id
self.model = model
self.content = content
self.usage = usage
self.flagged = flagged
self.flaggedCategories = flaggedCategories
self.finishReason = finishReason
}
}
// MARK: - Model Settings
/// Settings for model behavior
public struct ModelSettings: Codable, Sendable {
/// The model name/identifier
public let modelName: String
/// Temperature for randomness (0.0 to 2.0)
public let temperature: Double?
/// Top-p sampling parameter
public let topP: Double?
/// Maximum tokens to generate
public let maxTokens: Int?
/// Frequency penalty (-2.0 to 2.0)
public let frequencyPenalty: Double?
/// Presence penalty (-2.0 to 2.0)
public let presencePenalty: Double?
/// Stop sequences
public let stopSequences: [String]?
/// Tool choice setting
public let toolChoice: ToolChoice?
/// Whether to use parallel tool calls
public let parallelToolCalls: Bool?
/// Response format
public let responseFormat: ResponseFormat?
/// Seed for deterministic generation
public let seed: Int?
/// User identifier for tracking
public let user: String?
/// Additional provider-specific parameters
public let additionalParameters: ModelParameters?
public init(
modelName: String,
temperature: Double? = nil,
topP: Double? = nil,
maxTokens: Int? = nil,
frequencyPenalty: Double? = nil,
presencePenalty: Double? = nil,
stopSequences: [String]? = nil,
toolChoice: ToolChoice? = nil,
parallelToolCalls: Bool? = nil,
responseFormat: ResponseFormat? = nil,
seed: Int? = nil,
user: String? = nil,
additionalParameters: ModelParameters? = nil)
{
self.modelName = modelName
self.temperature = temperature
self.topP = topP
self.maxTokens = maxTokens
self.frequencyPenalty = frequencyPenalty
self.presencePenalty = presencePenalty
self.stopSequences = stopSequences
self.toolChoice = toolChoice
self.parallelToolCalls = parallelToolCalls
self.responseFormat = responseFormat
self.seed = seed
self.user = user
self.additionalParameters = additionalParameters
}
/// Default settings for Claude Opus 4
public static var `default`: ModelSettings {
ModelSettings(modelName: "claude-opus-4-20250514")
}
// MARK: - Convenience Constructors
/// Create settings with just specified parameters, using Claude Opus 4 as default model
public init(
temperature: Double? = nil,
topP: Double? = nil,
maxTokens: Int? = nil,
frequencyPenalty: Double? = nil,
presencePenalty: Double? = nil,
stopSequences: [String]? = nil,
toolChoice: ToolChoice? = nil,
parallelToolCalls: Bool? = nil,
responseFormat: ResponseFormat? = nil,
seed: Int? = nil,
user: String? = nil,
additionalParameters: ModelParameters? = nil)
{
self.init(
modelName: "claude-opus-4-20250514",
temperature: temperature,
topP: topP,
maxTokens: maxTokens,
frequencyPenalty: frequencyPenalty,
presencePenalty: presencePenalty,
stopSequences: stopSequences,
toolChoice: toolChoice,
parallelToolCalls: parallelToolCalls,
responseFormat: responseFormat,
seed: seed,
user: user,
additionalParameters: additionalParameters
)
}
/// Convenience constructors for specific API types and reasoning parameters
public init(
apiType: String,
modelName: String = "claude-opus-4-20250514")
{
let params = ModelParameters([
"apiType": ModelParameters.Value.string(apiType)
])
self.init(modelName: modelName, additionalParameters: params)
}
public init(
reasoningEffort: String,
reasoning: [String: String]? = nil,
temperature: Double? = nil,
modelName: String = "o3")
{
var params: [String: ModelParameters.Value] = [
"reasoningEffort": ModelParameters.Value.string(reasoningEffort)
]
if let reasoning = reasoning {
let reasoningValue = reasoning.mapValues { ModelParameters.Value.string($0) }
params["reasoning"] = ModelParameters.Value.dictionary(reasoningValue)
}
let modelParams = ModelParameters(params)
self.init(
modelName: modelName,
temperature: temperature,
additionalParameters: modelParams
)
}
// Custom coding for additionalParameters
enum CodingKeys: String, CodingKey {
case modelName, temperature, topP, maxTokens
case frequencyPenalty, presencePenalty, stopSequences
case toolChoice, parallelToolCalls, responseFormat
case seed, user, additionalParameters
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.modelName = try container.decode(String.self, forKey: .modelName)
self.temperature = try container.decodeIfPresent(Double.self, forKey: .temperature)
self.topP = try container.decodeIfPresent(Double.self, forKey: .topP)
self.maxTokens = try container.decodeIfPresent(Int.self, forKey: .maxTokens)
self.frequencyPenalty = try container.decodeIfPresent(Double.self, forKey: .frequencyPenalty)
self.presencePenalty = try container.decodeIfPresent(Double.self, forKey: .presencePenalty)
self.stopSequences = try container.decodeIfPresent([String].self, forKey: .stopSequences)
self.toolChoice = try container.decodeIfPresent(ToolChoice.self, forKey: .toolChoice)
self.parallelToolCalls = try container.decodeIfPresent(Bool.self, forKey: .parallelToolCalls)
self.responseFormat = try container.decodeIfPresent(ResponseFormat.self, forKey: .responseFormat)
self.seed = try container.decodeIfPresent(Int.self, forKey: .seed)
self.user = try container.decodeIfPresent(String.self, forKey: .user)
// Decode additional parameters
self.additionalParameters = try container.decodeIfPresent(ModelParameters.self, forKey: .additionalParameters)
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.modelName, forKey: .modelName)
try container.encodeIfPresent(self.temperature, forKey: .temperature)
try container.encodeIfPresent(self.topP, forKey: .topP)
try container.encodeIfPresent(self.maxTokens, forKey: .maxTokens)
try container.encodeIfPresent(self.frequencyPenalty, forKey: .frequencyPenalty)
try container.encodeIfPresent(self.presencePenalty, forKey: .presencePenalty)
try container.encodeIfPresent(self.stopSequences, forKey: .stopSequences)
try container.encodeIfPresent(self.toolChoice, forKey: .toolChoice)
try container.encodeIfPresent(self.parallelToolCalls, forKey: .parallelToolCalls)
try container.encodeIfPresent(self.responseFormat, forKey: .responseFormat)
try container.encodeIfPresent(self.seed, forKey: .seed)
try container.encodeIfPresent(self.user, forKey: .user)
// Encode additional parameters
try container.encodeIfPresent(self.additionalParameters, forKey: .additionalParameters)
}
}
// MARK: - Tool Choice
/// Tool choice setting for models
public enum ToolChoice: Codable, Sendable, Equatable {
case auto
case none
case required
case specific(toolName: String)
// Custom coding
enum CodingKeys: String, CodingKey {
case type, toolName
}
enum ChoiceType: String, Codable {
case auto, none, required, specific
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(ChoiceType.self, forKey: .type)
switch type {
case .auto:
self = .auto
case .none:
self = .none
case .required:
self = .required
case .specific:
let toolName = try container.decode(String.self, forKey: .toolName)
self = .specific(toolName: toolName)
}
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .auto:
try container.encode(ChoiceType.auto, forKey: .type)
case .none:
try container.encode(ChoiceType.none, forKey: .type)
case .required:
try container.encode(ChoiceType.required, forKey: .type)
case let .specific(toolName):
try container.encode(ChoiceType.specific, forKey: .type)
try container.encode(toolName, forKey: .toolName)
}
}
}
// MARK: - Response Format
/// Response format specification
public struct ResponseFormat: Codable, Sendable {
public let type: ResponseFormatType
public let jsonSchema: JSONSchema?
public init(type: ResponseFormatType, jsonSchema: JSONSchema? = nil) {
self.type = type
self.jsonSchema = jsonSchema
}
/// Plain text response
public static var text: ResponseFormat {
ResponseFormat(type: .text)
}
/// JSON object response
public static var jsonObject: ResponseFormat {
ResponseFormat(type: .jsonObject)
}
}
/// Response format types
public enum ResponseFormatType: String, Codable, Sendable {
case text
case jsonObject = "json_object"
case jsonSchema = "json_schema"
}
/// JSON schema specification
public struct JSONSchema: Codable, Sendable {
public let name: String
public let strict: Bool
private let schemaData: Data // Store as raw JSON data
// Custom coding for schema
enum CodingKeys: String, CodingKey {
case name, strict, schema
}
public init(name: String, strict: Bool = true, schema: [String: Any]) throws {
self.name = name
self.strict = strict
self.schemaData = try JSONSerialization.data(withJSONObject: schema)
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
self.strict = try container.decode(Bool.self, forKey: .strict)
self.schemaData = try container.decode(Data.self, forKey: .schema)
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.name, forKey: .name)
try container.encode(self.strict, forKey: .strict)
try container.encode(self.schemaData, forKey: .schema)
}
/// Get the schema as a dictionary
public func getSchema() throws -> [String: Any] {
guard let dict = try JSONSerialization.jsonObject(with: schemaData) as? [String: Any] else {
throw TachikomaError.invalidConfiguration("Invalid JSON schema data")
}
return dict
}
}
// MARK: - Model Provider Protocol
/// Protocol for model provider factories
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
public protocol ModelProviderProtocol {
/// Get a model by name
/// - Parameter modelName: The name of the model to retrieve
/// - Returns: A model instance conforming to ModelInterface
func getModel(modelName: String) throws -> any ModelInterface
}
// MARK: - Model Errors
// MARK: - Note
// ModelError is defined in TachikomaError.swift to avoid duplication