diff --git a/Sources/OpenFoundationModelsOpenAI/Internal/TranscriptConverter.swift b/Sources/OpenFoundationModelsOpenAI/Internal/TranscriptConverter.swift index dc7c6ec..50bb343 100644 --- a/Sources/OpenFoundationModelsOpenAI/Internal/TranscriptConverter.swift +++ b/Sources/OpenFoundationModelsOpenAI/Internal/TranscriptConverter.swift @@ -435,46 +435,58 @@ internal struct TranscriptConverter { } /// Extract response format with full JSON Schema from the most recent prompt - static func extractResponseFormatWithSchema(from transcript: Transcript) -> ResponseFormat? { + static func extractResponseFormatWithSchema(from transcript: Transcript, for model: OpenAIModel) -> ResponseFormat? { // Try JSON-based extraction to get complete schema - return extractResponseFormatFromJSON(transcript) + return extractResponseFormatFromJSON(transcript, for: model) } /// Extract response format by encoding Transcript to JSON private static func extractResponseFormatFromJSON(_ transcript: Transcript) -> ResponseFormat? { + // For backward compatibility, default to GPT model behavior + // This method is used by extractResponseFormat which doesn't have model context + return extractResponseFormatFromJSON(transcript, for: OpenAIModel("gpt-4o")) + } + + /// Extract response format by encoding Transcript to JSON + private static func extractResponseFormatFromJSON(_ transcript: Transcript, for model: OpenAIModel) -> ResponseFormat? { do { // Encode transcript to JSON let encoder = JSONEncoder() let data = try encoder.encode(transcript) - + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let entries = json["entries"] as? [[String: Any]] else { return nil } - + // Look for the most recent prompt with responseFormat for entry in entries.reversed() { if entry["type"] as? String == "prompt", let responseFormat = entry["responseFormat"] as? [String: Any] { - + #if DEBUG print("Found response format in JSON: \(responseFormat)") #endif - + // Check if there's a schema (now available with updated OpenFoundationModels) if let schema = responseFormat["schema"] as? [String: Any] { - // Transform schema to OpenAI's expected format - let transformedSchema = transformToOpenAIJSONSchema(schema) - return .jsonSchema(transformedSchema) + // For models that don't support json_schema (like DeepSeek), fallback to json mode + if model.modelType == .deepseek { + return .json + } else { + // Transform schema to OpenAI's expected format + let transformedSchema = transformToOpenAIJSONSchema(schema) + return .jsonSchema(transformedSchema) + } } - + // If there's a name or type field, we know JSON is expected if responseFormat["name"] != nil || responseFormat["type"] != nil { return .json } } } - + return nil } catch { #if DEBUG diff --git a/Sources/OpenFoundationModelsOpenAI/Models/OpenAIModel.swift b/Sources/OpenFoundationModelsOpenAI/Models/OpenAIModel.swift index 3137228..9624de0 100644 --- a/Sources/OpenFoundationModelsOpenAI/Models/OpenAIModel.swift +++ b/Sources/OpenFoundationModelsOpenAI/Models/OpenAIModel.swift @@ -65,6 +65,8 @@ public struct OpenAIModel: Sendable, Hashable, ExpressibleByStringLiteral { return 128_000 case .reasoning: return 200_000 + case .deepseek: + return 32_768 // DeepSeek models typically have 32K context } } @@ -80,6 +82,8 @@ public struct OpenAIModel: Sendable, Hashable, ExpressibleByStringLiteral { return 16_384 case .reasoning: return 100_000 + case .deepseek: + return 8_192 // DeepSeek models typically have 8K max output } } @@ -90,6 +94,8 @@ public struct OpenAIModel: Sendable, Hashable, ExpressibleByStringLiteral { return [.textGeneration, .vision, .functionCalling, .streaming, .toolAccess] case .reasoning: return [.textGeneration, .reasoning, .functionCalling, .streaming, .toolAccess] + case .deepseek: + return [.textGeneration, .functionCalling, .streaming, .toolAccess] // No vision or reasoning } } @@ -118,6 +124,17 @@ public struct OpenAIModel: Sendable, Hashable, ExpressibleByStringLiteral { temperatureRange: nil, topPRange: nil ) + case .deepseek: + return ParameterConstraints( + supportsTemperature: true, + supportsTopP: true, + supportsFrequencyPenalty: true, + supportsPresencePenalty: true, + supportsStop: true, + maxTokensParameterName: "max_tokens", + temperatureRange: 0.0...2.0, + topPRange: 0.0...1.0 + ) } } @@ -154,6 +171,11 @@ public struct OpenAIModel: Sendable, Hashable, ExpressibleByStringLiteral { return .reasoning } + // DeepSeek models + if lowercased.contains("deepseek") { + return .deepseek + } + // Default to GPT for all other models return .gpt } @@ -165,6 +187,7 @@ public struct OpenAIModel: Sendable, Hashable, ExpressibleByStringLiteral { public enum ModelType: String, Sendable, Hashable { case gpt case reasoning + case deepseek } /// Model capabilities using OptionSet @@ -255,4 +278,4 @@ extension OpenAIModel { public static func == (lhs: OpenAIModel, rhs: OpenAIModel) -> Bool { return lhs.id == rhs.id } -} +} \ No newline at end of file diff --git a/Sources/OpenFoundationModelsOpenAI/OpenAILanguageModel.swift b/Sources/OpenFoundationModelsOpenAI/OpenAILanguageModel.swift index 9500f0a..b7878e6 100644 --- a/Sources/OpenFoundationModelsOpenAI/OpenAILanguageModel.swift +++ b/Sources/OpenFoundationModelsOpenAI/OpenAILanguageModel.swift @@ -34,6 +34,10 @@ public final class OpenAILanguageModel: LanguageModel, @unchecked Sendable { case .reasoning: self.requestBuilder = ReasoningRequestBuilder() self.responseHandler = ReasoningResponseHandler() + case .deepseek: + // DeepSeek uses similar API to GPT models + self.requestBuilder = GPTRequestBuilder() + self.responseHandler = GPTResponseHandler() } self.rateLimiter = RateLimiter(configuration: configuration.rateLimits) } @@ -52,7 +56,7 @@ public final class OpenAILanguageModel: LanguageModel, @unchecked Sendable { // Use TranscriptConverter for all conversions let messages = TranscriptConverter.buildMessages(from: transcript) let tools = TranscriptConverter.extractTools(from: transcript) - let responseFormat = TranscriptConverter.extractResponseFormatWithSchema(from: transcript) + let responseFormat = TranscriptConverter.extractResponseFormatWithSchema(from: transcript, for: model) let finalOptions = options ?? TranscriptConverter.extractOptions(from: transcript) // Build request with response format if present @@ -97,7 +101,7 @@ public final class OpenAILanguageModel: LanguageModel, @unchecked Sendable { // Use TranscriptConverter for all conversions let messages = TranscriptConverter.buildMessages(from: transcript) let tools = TranscriptConverter.extractTools(from: transcript) - let responseFormat = TranscriptConverter.extractResponseFormatWithSchema(from: transcript) + let responseFormat = TranscriptConverter.extractResponseFormatWithSchema(from: transcript, for: model) let finalOptions = options ?? TranscriptConverter.extractOptions(from: transcript) let request = try buildChatRequestWithFormat( @@ -342,11 +346,16 @@ public final class OpenAILanguageModel: LanguageModel, @unchecked Sendable { /// Convert GenerationSchema to ResponseFormat private func convertSchemaToResponseFormat(_ schema: GenerationSchema) -> ResponseFormat { + // For models that don't support json_schema (like DeepSeek), fallback to json mode + if model.modelType == .deepseek { + return .json + } + // Encode GenerationSchema to get JSON Schema do { let encoder = JSONEncoder() let schemaData = try encoder.encode(schema) - + // Convert to JSON dictionary if let schemaJson = try JSONSerialization.jsonObject(with: schemaData) as? [String: Any] { // Transform to OpenAI's expected JSON Schema format @@ -356,7 +365,7 @@ public final class OpenAILanguageModel: LanguageModel, @unchecked Sendable { } catch { print("Warning: Failed to convert GenerationSchema to ResponseFormat: \(error)") } - + // Fallback to JSON mode return .json } diff --git a/Tests/OpenFoundationModelsOpenAITests/DeepSeekCompatibilityTests.swift b/Tests/OpenFoundationModelsOpenAITests/DeepSeekCompatibilityTests.swift new file mode 100644 index 0000000..8002e85 --- /dev/null +++ b/Tests/OpenFoundationModelsOpenAITests/DeepSeekCompatibilityTests.swift @@ -0,0 +1,257 @@ +import Testing +import Foundation +@testable import OpenFoundationModelsOpenAI +import OpenFoundationModels + +/// Tests specifically for DeepSeek API compatibility +@Suite("DeepSeek Compatibility Tests") +struct DeepSeekCompatibilityTests { + + // MARK: - Model Identification Tests + + @Test("DeepSeek model identification works correctly") + func testDeepSeekModelIdentification() { + // Test various DeepSeek model names + let deepSeekModels = ["deepseek-chat", "deepseek-coder", "deepseek-v3", "deepseek-r1"] + + for modelName in deepSeekModels { + let model = OpenAIModel(modelName) + #expect(model.modelType == .deepseek, "Model \(modelName) should be identified as DeepSeek") + #expect(model.apiName == modelName, "API name should match model name") + } + + // Test that non-DeepSeek models are not identified as DeepSeek + let nonDeepSeekModels = ["gpt-4o", "gpt-4-turbo", "o1", "claude-3"] + + for modelName in nonDeepSeekModels { + let model = OpenAIModel(modelName) + #expect(model.modelType != .deepseek, "Model \(modelName) should not be identified as DeepSeek") + } + } + + @Test("DeepSeek models have correct capabilities") + func testDeepSeekModelCapabilities() { + let deepSeekModel = OpenAIModel("deepseek-chat") + + // DeepSeek should support text generation, function calling, and streaming + #expect(deepSeekModel.supportsVision == false, "DeepSeek should not support vision") + #expect(deepSeekModel.supportsFunctionCalling == true, "DeepSeek should support function calling") + #expect(deepSeekModel.supportsStreaming == true, "DeepSeek should support streaming") + #expect(deepSeekModel.isReasoningModel == false, "DeepSeek should not be a reasoning model") + + // Check capabilities set + #expect(deepSeekModel.capabilities.contains(.textGeneration), "DeepSeek should support text generation") + #expect(deepSeekModel.capabilities.contains(.functionCalling), "DeepSeek should support function calling") + #expect(deepSeekModel.capabilities.contains(.streaming), "DeepSeek should support streaming") + #expect(deepSeekModel.capabilities.contains(.toolAccess), "DeepSeek should support tool access") + #expect(!deepSeekModel.capabilities.contains(.vision), "DeepSeek should not support vision") + #expect(!deepSeekModel.capabilities.contains(.reasoning), "DeepSeek should not support reasoning") + } + + @Test("DeepSeek models have correct context window and tokens") + func testDeepSeekModelLimits() { + let deepSeekModel = OpenAIModel("deepseek-chat") + + #expect(deepSeekModel.contextWindow == 32768, "DeepSeek should have 32K context window") + #expect(deepSeekModel.maxOutputTokens == 8192, "DeepSeek should have 8K max output tokens") + } + + // MARK: - Response Format Compatibility Tests + + @Test("DeepSeek models use JSON response format instead of JSON Schema") + func testDeepSeekResponseFormatCompatibility() throws { + // Create a simple schema for testing + let schema = GenerationSchema( + type: String.self, + description: "Test response", + properties: [] + ) + + // Test with GPT model (should use json_schema) + let gptModel = OpenAIModel("gpt-4o") + let gptResponseFormat = convertSchemaToResponseFormat(schema, for: gptModel) + + // Test with DeepSeek model (should use json) + let deepSeekModel = OpenAIModel("deepseek-chat") + let deepSeekResponseFormat = convertSchemaToResponseFormat(schema, for: deepSeekModel) + + // GPT should use jsonSchema format + switch gptResponseFormat { + case .jsonSchema: + // Expected for GPT models + break + default: + #expect(Bool(false), "GPT model should use jsonSchema format") + } + + // DeepSeek should use json format + switch deepSeekResponseFormat { + case .json: + // Expected for DeepSeek models + break + case .jsonSchema: + #expect(Bool(false), "DeepSeek model should not use jsonSchema format") + case .text: + #expect(Bool(false), "DeepSeek model should not use text format") + } + } + + @Test("TranscriptConverter respects model type for response format extraction") + func testTranscriptConverterDeepSeekResponseFormat() throws { + // Create a transcript with a prompt that has a response format + let schema = GenerationSchema( + type: String.self, + description: "Response schema", + properties: [] + ) + + let responseFormat = Transcript.ResponseFormat(schema: schema) + + let transcript = Transcript( + entries: [ + .prompt( + Transcript.Prompt( + segments: [.text(Transcript.TextSegment(content: "Generate a response"))], + responseFormat: responseFormat + ) + ) + ] + ) + + // Test with GPT model + let gptModel = OpenAIModel("gpt-4o") + let gptExtractedFormat = TranscriptConverter.extractResponseFormatWithSchema(from: transcript, for: gptModel) + + // Test with DeepSeek model + let deepSeekModel = OpenAIModel("deepseek-chat") + let deepSeekExtractedFormat = TranscriptConverter.extractResponseFormatWithSchema(from: transcript, for: deepSeekModel) + + // GPT should extract jsonSchema format + switch gptExtractedFormat { + case .jsonSchema: + // Expected + break + default: + #expect(Bool(false), "GPT model should extract jsonSchema format") + } + + // DeepSeek should extract json format + switch deepSeekExtractedFormat { + case .json: + // Expected + break + case .jsonSchema: + #expect(Bool(false), "DeepSeek model should extract json format, not jsonSchema") + default: + #expect(Bool(false), "DeepSeek model should extract json format") + } + } + + // MARK: - Request Builder Compatibility Tests + + @Test("DeepSeek models use correct request builder") + func testDeepSeekRequestBuilder() { + let deepSeekModel = OpenAIModel("deepseek-chat") + + // DeepSeek should use GPTRequestBuilder, not ReasoningRequestBuilder + switch deepSeekModel.modelType { + case .deepseek: + // This should work without throwing + let builder = GPTRequestBuilder() + #expect(type(of: builder) == GPTRequestBuilder.self) + default: + #expect(Bool(false), "DeepSeek model should have deepseek type") + } + } + + @Test("DeepSeek models use correct response handler") + func testDeepSeekResponseHandler() { + let deepSeekModel = OpenAIModel("deepseek-chat") + + // DeepSeek should use GPTResponseHandler, not ReasoningResponseHandler + switch deepSeekModel.modelType { + case .deepseek: + // This should work without throwing + let handler = GPTResponseHandler() + #expect(type(of: handler) == GPTResponseHandler.self) + default: + #expect(Bool(false), "DeepSeek model should have deepseek type") + } + } +} + +// MARK: - Helper Functions for Testing + +/// Convert GenerationSchema to ResponseFormat for a specific model (test helper) +private func convertSchemaToResponseFormat(_ schema: GenerationSchema, for model: OpenAIModel) -> ResponseFormat { + // This replicates the logic from OpenAILanguageModel.convertSchemaToResponseFormat + // For models that don't support json_schema (like DeepSeek), fallback to json mode + if model.modelType == .deepseek { + return .json + } + + // For other models, try to create jsonSchema format + do { + let encoder = JSONEncoder() + let schemaData = try encoder.encode(schema) + + // Convert to JSON dictionary + if let schemaJson = try JSONSerialization.jsonObject(with: schemaData) as? [String: Any] { + // Transform to OpenAI's expected JSON Schema format + let transformedSchema = transformToOpenAIJSONSchema(schemaJson) + return .jsonSchema(transformedSchema) + } + } catch { + // Ignore encoding errors in test + } + + // Fallback to JSON mode + return .json +} + +/// Transform GenerationSchema JSON to OpenAI's JSON Schema format (test helper) +private func transformToOpenAIJSONSchema(_ json: [String: Any]) -> [String: Any] { + var schema: [String: Any] = [:] + + // Extract type (default to "object") + schema["type"] = json["type"] as? String ?? "object" + + // Extract and transform properties + if let properties = json["properties"] as? [String: [String: Any]] { + var transformedProperties: [String: [String: Any]] = [:] + + for (key, propJson) in properties { + var prop: [String: Any] = [:] + prop["type"] = propJson["type"] as? String ?? "string" + + if let description = propJson["description"] as? String { + prop["description"] = description + } + + if let enumValues = propJson["enum"] as? [String] { + prop["enum"] = enumValues + } + + if prop["type"] as? String == "array", + let items = propJson["items"] as? [String: Any] { + prop["items"] = items + } + + transformedProperties[key] = prop + } + + schema["properties"] = transformedProperties + } + + // Extract required fields + if let required = json["required"] as? [String] { + schema["required"] = required + } + + // Add description if present + if let description = json["description"] as? String { + schema["description"] = description + } + + return schema +} \ No newline at end of file diff --git a/Tests/OpenFoundationModelsOpenAITests/IntegrationTests.swift b/Tests/OpenFoundationModelsOpenAITests/IntegrationTests.swift index 2e69b9c..504a4f6 100644 --- a/Tests/OpenFoundationModelsOpenAITests/IntegrationTests.swift +++ b/Tests/OpenFoundationModelsOpenAITests/IntegrationTests.swift @@ -402,6 +402,11 @@ struct IntegrationTests { let builder = ReasoningRequestBuilder() #expect(type(of: builder) == ReasoningRequestBuilder.self, "Model \(model.apiName) should use ReasoningRequestBuilder") + case .deepseek: + // DeepSeek models should use GPTRequestBuilder + let builder = GPTRequestBuilder() + #expect(type(of: builder) == GPTRequestBuilder.self, + "Model \(model.apiName) should use GPTRequestBuilder") } } } @@ -534,4 +539,4 @@ struct IntegrationTests { #expect(tools?.count == 1) #expect(tools?.first?.function.name == "get_stock_price") } -} +} \ No newline at end of file diff --git a/Tests/OpenFoundationModelsOpenAITests/RequestBuilderTests.swift b/Tests/OpenFoundationModelsOpenAITests/RequestBuilderTests.swift index ab825ed..6f29f3b 100644 --- a/Tests/OpenFoundationModelsOpenAITests/RequestBuilderTests.swift +++ b/Tests/OpenFoundationModelsOpenAITests/RequestBuilderTests.swift @@ -243,15 +243,20 @@ struct RequestBuilderTests { builder = GPTRequestBuilder() case .reasoning: builder = ReasoningRequestBuilder() + case .deepseek: + builder = GPTRequestBuilder() // DeepSeek uses GPT builder } - + switch model.modelType { case .gpt: - #expect(type(of: builder) == GPTRequestBuilder.self, + #expect(type(of: builder) == GPTRequestBuilder.self, "Should create GPTRequestBuilder for GPT model \(model.apiName)") case .reasoning: - #expect(type(of: builder) == ReasoningRequestBuilder.self, + #expect(type(of: builder) == ReasoningRequestBuilder.self, "Should create ReasoningRequestBuilder for reasoning model \(model.apiName)") + case .deepseek: + #expect(type(of: builder) == GPTRequestBuilder.self, + "Should create GPTRequestBuilder for DeepSeek model \(model.apiName)") } } diff --git a/Tests/OpenFoundationModelsOpenAITests/ResponseHandlerTests.swift b/Tests/OpenFoundationModelsOpenAITests/ResponseHandlerTests.swift index 02a2695..7c47a70 100644 --- a/Tests/OpenFoundationModelsOpenAITests/ResponseHandlerTests.swift +++ b/Tests/OpenFoundationModelsOpenAITests/ResponseHandlerTests.swift @@ -220,15 +220,20 @@ struct ResponseHandlerTests { handler = GPTResponseHandler() case .reasoning: handler = ReasoningResponseHandler() + case .deepseek: + handler = GPTResponseHandler() // DeepSeek uses GPT handler } - + switch model.modelType { case .gpt: - #expect(type(of: handler) == GPTResponseHandler.self, + #expect(type(of: handler) == GPTResponseHandler.self, "Should create GPTResponseHandler for GPT model \(model.apiName)") case .reasoning: - #expect(type(of: handler) == ReasoningResponseHandler.self, + #expect(type(of: handler) == ReasoningResponseHandler.self, "Should create ReasoningResponseHandler for reasoning model \(model.apiName)") + case .deepseek: + #expect(type(of: handler) == GPTResponseHandler.self, + "Should create GPTResponseHandler for DeepSeek model \(model.apiName)") } }