diff --git a/mreader/AIModelProtocol.swift b/mreader/AIModelProtocol.swift index 1c0f8cf9..66cfd2c0 100644 --- a/mreader/AIModelProtocol.swift +++ b/mreader/AIModelProtocol.swift @@ -18,6 +18,38 @@ nonisolated enum AIAPIProtocol: String, Codable, CaseIterable, Sendable { } } +nonisolated enum AIProviderInterfaceType: String, Codable, CaseIterable, Hashable, Sendable { + case compatible + case native + + var displayName: String { + switch self { + case .compatible: + return "兼容接口" + case .native: + return "原生接口" + } + } + + var detailText: String { + switch self { + case .compatible: + return "按 OpenAI Chat Completions 兼容协议请求,适合中转、聚合和大多数第三方接口。" + case .native: + return "按每个模型配置的原生 API 协议请求,可使用 Chat Completions、Responses 或 Anthropic Messages。" + } + } + + func effectiveProtocol(for configuredProtocol: AIAPIProtocol) -> AIAPIProtocol { + switch self { + case .compatible: + return .openAIChatCompletions + case .native: + return configuredProtocol + } + } +} + nonisolated struct AIModelDescriptor: Codable, Hashable, Sendable { let id: String var apiProtocol: AIAPIProtocol @@ -90,6 +122,37 @@ nonisolated enum AITransportResponseFormat: Sendable, Equatable { case jsonSchema(name: String, schema: Data) } +/// Some OpenAI-compatible gateways return 400 for structured-output parameters +/// even when the endpoint itself is otherwise usable. Keep the classification +/// centralized so page translation, vision translation, and error reporting +/// agree on when it is safe to retry without response_format. +nonisolated enum AIResponseFormatCompatibility { + static func isUnsupportedMessage(_ message: String) -> Bool { + let normalized = message.lowercased() + let mentionsFormat = normalized.contains("response_format") + || normalized.contains("text.format") + || normalized.contains("json_schema") + || normalized.contains("json schema") + || normalized.contains("structured output") + let unsupported = normalized.contains("unsupported") + || normalized.contains("not support") + || normalized.contains("not allowed") + || normalized.contains("unknown parameter") + || normalized.contains("invalid parameter") + || normalized.contains("invalid schema") + || normalized.contains("schema validation") + || normalized.contains("invalid response format") + || normalized.contains("unavailable") + || normalized.contains("not available") + || normalized.contains("temporarily unavailable") + return mentionsFormat && unsupported + } + + static func isUnsupported(_ error: Error) -> Bool { + isUnsupportedMessage(error.localizedDescription) + } +} + /// Identifies the user-visible operation for timeout/retry policy and /// diagnostics. Keeping this on the transport request prevents settings /// probes, page translations and vision calls from sharing one timeout. diff --git a/mreader/AIProviderSettingsView.swift b/mreader/AIProviderSettingsView.swift index 54047655..074df259 100644 --- a/mreader/AIProviderSettingsView.swift +++ b/mreader/AIProviderSettingsView.swift @@ -131,9 +131,14 @@ struct AIProviderSettingsView: View { VStack(alignment: .leading, spacing: 3) { Text(profile.name) .foregroundStyle(.primary) - Text(profile.selectedTextModel) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + HStack(spacing: 5) { + Text(profile.interfaceType.displayName) + Text("·") + Text(profile.selectedTextModel) + .fontDesign(.monospaced) + } + .font(.caption) + .foregroundStyle(.secondary) Text(profile.baseURL) .font(.caption2) .foregroundStyle(.tertiary) @@ -291,6 +296,7 @@ private struct AIProviderEditorView: View { @State private var createdAt: Date @State private var name: String @State private var baseURL: String + @State private var interfaceType: AIProviderInterfaceType @State private var apiKey: String @State private var modelsText: String @State private var modelDescriptors: [String: AIModelDescriptor] @@ -318,6 +324,7 @@ private struct AIProviderEditorView: View { _createdAt = State(initialValue: profile?.createdAt ?? Date()) _name = State(initialValue: profile?.name ?? "") _baseURL = State(initialValue: profile?.baseURL ?? "https://api.openai.com/v1") + _interfaceType = State(initialValue: profile?.interfaceType ?? .compatible) _apiKey = State(initialValue: profile.map { AIProviderStore.shared.apiKey(for: $0.id) } ?? "") _modelsText = State(initialValue: profile?.models.joined(separator: "\n") ?? "gpt-4o-mini") _modelDescriptors = State( @@ -343,6 +350,14 @@ private struct AIProviderEditorView: View { Form { Section("aiProvider.connection".localized) { TextField("aiProvider.name".localized, text: $name) + Picker("接口类型", selection: $interfaceType) { + ForEach(AIProviderInterfaceType.allCases, id: \.self) { type in + Text(type.displayName).tag(type) + } + } + Text(interfaceType.detailText) + .font(.caption) + .foregroundStyle(.secondary) TextField("settings.baseUrl".localized, text: $baseURL) .keyboardType(.URL) .textInputAutocapitalization(.never) @@ -449,6 +464,7 @@ private struct AIProviderEditorView: View { NavigationStack { AIModelDescriptorEditorView( modelID: item.id, + interfaceType: interfaceType, descriptor: descriptor(for: item.id) ) { updatedDescriptor in let repairedVisionModel = AIProviderModelSelectionPolicy.repairedVisionModel( @@ -482,6 +498,7 @@ private struct AIProviderEditorView: View { id: profileID, name: name, baseURL: baseURL, + interfaceType: interfaceType, modelsText: modelsText, selectedTextModel: selectedTextModel, selectedVisionModel: selectedVisionModel, @@ -523,8 +540,9 @@ private struct AIProviderEditorView: View { ? (normalizedModels.contains(selectedTextModel) ? selectedTextModel : (normalizedModels.first ?? "")) : (visionModels.contains(selectedVisionModel) ? selectedVisionModel : (visionModels.first ?? "")) guard !model.isEmpty else { return } - let modelDescriptor = descriptor(for: model) - guard kind != .vision || modelDescriptor.supportsVision != false else { + let configuredDescriptor = descriptor(for: model) + let modelDescriptor = transportDescriptor(for: model) + guard kind != .vision || configuredDescriptor.supportsVision != false else { testFailed = true testMessage = "当前模型明确不支持视觉输入。" return @@ -587,10 +605,10 @@ private struct AIProviderEditorView: View { let passed: Bool if kind == .vision { passed = AIVisionConnectionProbe.response(content, contains: challenge) - if passed, modelDescriptor.supportsVision != true { + if passed, configuredDescriptor.supportsVision != true { modelDescriptors[model] = AIModelDescriptor( - id: modelDescriptor.id, - apiProtocol: modelDescriptor.apiProtocol, + id: configuredDescriptor.id, + apiProtocol: configuredDescriptor.apiProtocol, supportsVision: true ) } @@ -649,6 +667,15 @@ private struct AIProviderEditorView: View { modelDescriptors[model] ?? AIModelProtocolCatalog.descriptor(for: model) } + private func transportDescriptor(for model: String) -> AIModelDescriptor { + let configured = descriptor(for: model) + return AIModelDescriptor( + id: configured.id, + apiProtocol: interfaceType.effectiveProtocol(for: configured.apiProtocol), + supportsVision: configured.supportsVision + ) + } + @ViewBuilder private func modelSummaryRow(for model: String) -> some View { let modelDescriptor = descriptor(for: model) @@ -659,7 +686,9 @@ private struct AIProviderEditorView: View { .foregroundStyle(.primary) .lineLimit(1) HStack(spacing: 8) { - Text(modelDescriptor.apiProtocol.displayName) + Text(interfaceType == .compatible + ? "兼容 · Chat Completions" + : "原生 · \(modelDescriptor.apiProtocol.displayName)") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -698,6 +727,7 @@ private struct AIModelDescriptorEditorView: View { @Environment(\.dismiss) private var dismiss let modelID: String + let interfaceType: AIProviderInterfaceType let onSave: (AIModelDescriptor) -> Void @State private var apiProtocol: AIAPIProtocol @@ -705,10 +735,12 @@ private struct AIModelDescriptorEditorView: View { init( modelID: String, + interfaceType: AIProviderInterfaceType, descriptor: AIModelDescriptor, onSave: @escaping (AIModelDescriptor) -> Void ) { self.modelID = modelID + self.interfaceType = interfaceType self.onSave = onSave _apiProtocol = State(initialValue: descriptor.apiProtocol) _visionCapability = State(initialValue: AIModelVisionCapability( @@ -719,10 +751,17 @@ private struct AIModelDescriptorEditorView: View { var body: some View { Form { Section { - Picker("API 协议", selection: $apiProtocol) { - ForEach(AIAPIProtocol.allCases, id: \.self) { value in - Text(value.displayName).tag(value) + if interfaceType == .native { + Picker("API 协议", selection: $apiProtocol) { + ForEach(AIAPIProtocol.allCases, id: \.self) { value in + Text(value.displayName).tag(value) + } } + } else { + LabeledContent("API 协议", value: "Chat Completions") + Text("兼容接口固定使用 OpenAI Chat Completions 协议;切换回原生接口时,会恢复这里保存的原生协议设置。") + .font(.caption) + .foregroundStyle(.secondary) } } header: { Text("API 协议") diff --git a/mreader/AIProviderStore.swift b/mreader/AIProviderStore.swift index 136ec3f1..e43628b3 100644 --- a/mreader/AIProviderStore.swift +++ b/mreader/AIProviderStore.swift @@ -5,6 +5,7 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable var id: UUID var name: String var baseURL: String + var interfaceType: AIProviderInterfaceType var models: [String] var modelDescriptors: [AIModelDescriptor] /// 纯文本翻译模型:OCR 识别后的整页文字翻译、Apple 翻译云端兜底。 @@ -18,6 +19,7 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable case id case name case baseURL + case interfaceType case models case modelDescriptors case selectedModel @@ -31,6 +33,7 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable id: UUID, name: String, baseURL: String, + interfaceType: AIProviderInterfaceType = .compatible, models: [String], selectedTextModel: String, selectedVisionModel: String, @@ -41,6 +44,7 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable self.id = id self.name = name self.baseURL = baseURL + self.interfaceType = interfaceType self.models = models self.modelDescriptors = AIModelProtocolCatalog.descriptors( for: models, @@ -58,6 +62,13 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) baseURL = try container.decode(String.self, forKey: .baseURL) + // Profiles saved before interfaceType existed may already contain + // Responses/Anthropic model descriptors. Preserve that behavior by + // treating them as native instead of silently forcing Chat Completions. + interfaceType = try container.decodeIfPresent( + AIProviderInterfaceType.self, + forKey: .interfaceType + ) ?? .native models = try container.decode([String].self, forKey: .models) if let storedDescriptors = try container.decodeIfPresent( [AIModelDescriptor].self, @@ -85,6 +96,7 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) try container.encode(baseURL, forKey: .baseURL) + try container.encode(interfaceType, forKey: .interfaceType) try container.encode(models, forKey: .models) try container.encode(modelDescriptors, forKey: .modelDescriptors) try container.encode(selectedTextModel, forKey: .selectedTextModel) @@ -97,6 +109,7 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable id: UUID = UUID(), name: String, baseURL: String, + interfaceType: AIProviderInterfaceType = .compatible, modelsText: String, selectedTextModel: String, selectedVisionModel: String, @@ -115,6 +128,7 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable ? "默认接口" : name.trimmingCharacters(in: .whitespacesAndNewlines), baseURL: normalizedBaseURL(baseURL), + interfaceType: interfaceType, models: models, selectedTextModel: pick(selectedTextModel), selectedVisionModel: pick(selectedVisionModel), @@ -129,6 +143,19 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable ?? AIModelProtocolCatalog.descriptor(for: modelID) } + /// Descriptor actually used on the wire. Compatible providers deliberately + /// speak OpenAI Chat Completions even when a model name happens to match a + /// native-protocol catalog entry; native providers keep the configured + /// per-model protocol. + func requestDescriptor(for modelID: String) -> AIModelDescriptor { + let configured = descriptor(for: modelID) + return AIModelDescriptor( + id: configured.id, + apiProtocol: interfaceType.effectiveProtocol(for: configured.apiProtocol), + supportsVision: configured.supportsVision + ) + } + static func fromLegacySettings( apiDisplayName: String, baseURL: String, @@ -143,6 +170,7 @@ nonisolated struct AIProviderProfile: Identifiable, Codable, Hashable, Sendable return normalized( name: apiDisplayName, baseURL: baseURL, + interfaceType: .compatible, modelsText: combined, selectedTextModel: primary, selectedVisionModel: primary, @@ -171,6 +199,7 @@ nonisolated struct AIActiveConfiguration: Sendable, Equatable { let profileID: UUID let profileName: String let baseURL: String + let interfaceType: AIProviderInterfaceType let apiKey: String /// 文本翻译模型(OCR 后文字翻译 / Apple 云端兜底)。 let textModel: String @@ -183,6 +212,7 @@ nonisolated struct AIActiveConfiguration: Sendable, Equatable { profileID: UUID, profileName: String, baseURL: String, + interfaceType: AIProviderInterfaceType = .native, apiKey: String, textModel: String, visionModel: String, @@ -192,6 +222,7 @@ nonisolated struct AIActiveConfiguration: Sendable, Equatable { self.profileID = profileID self.profileName = profileName self.baseURL = baseURL + self.interfaceType = interfaceType self.apiKey = apiKey self.textModel = textModel self.visionModel = visionModel @@ -334,11 +365,12 @@ final class AIProviderStore { profileID: profile.id, profileName: profile.name, baseURL: profile.baseURL, + interfaceType: profile.interfaceType, apiKey: apiKey, textModel: textModel, visionModel: visionModel, - textModelDescriptor: profile.descriptor(for: textModel), - visionModelDescriptor: profile.descriptor(for: visionModel) + textModelDescriptor: profile.requestDescriptor(for: textModel), + visionModelDescriptor: profile.requestDescriptor(for: visionModel) ) } diff --git a/mreader/AITranslator.swift b/mreader/AITranslator.swift index ae2133c7..75dbda6d 100644 --- a/mreader/AITranslator.swift +++ b/mreader/AITranslator.swift @@ -299,21 +299,7 @@ nonisolated enum AITranslationRequestError: LocalizedError, Sendable { return true case .server(_, _, let message), .serverWithRetryAfter(_, _, let message, _): - let normalized = message.lowercased() - let mentionsFormat = normalized.contains("response_format") - || normalized.contains("text.format") - || normalized.contains("json_schema") - || normalized.contains("json schema") - || normalized.contains("structured output") - let unsupported = normalized.contains("unsupported") - || normalized.contains("not support") - || normalized.contains("not allowed") - || normalized.contains("unknown parameter") - || normalized.contains("invalid parameter") - || normalized.contains("invalid schema") - || normalized.contains("schema validation") - || normalized.contains("invalid response format") - return mentionsFormat && unsupported + return AIResponseFormatCompatibility.isUnsupportedMessage(message) default: return false } } @@ -2537,20 +2523,7 @@ static func visualReviewedBlockForDiagnostics(original: TextBlock, review: Visio } private static func isUnsupportedResponseFormat(_ error: Error) -> Bool { - let message = error.localizedDescription.lowercased() - let mentionsFormat = message.contains("response_format") - || message.contains("json_schema") - || message.contains("json schema") - || message.contains("structured output") - let unsupported = message.contains("unsupported") - || message.contains("not support") - || message.contains("not allowed") - || message.contains("unknown parameter") - || message.contains("invalid parameter") - || message.contains("invalid schema") - || message.contains("schema validation") - || message.contains("invalid response format") - return mentionsFormat && unsupported + AIResponseFormatCompatibility.isUnsupported(error) } private static func transportResponseFormat( diff --git a/mreader/OfflineTranslationCoordinator.swift b/mreader/OfflineTranslationCoordinator.swift index 126c8a51..b73045af 100644 --- a/mreader/OfflineTranslationCoordinator.swift +++ b/mreader/OfflineTranslationCoordinator.swift @@ -1631,8 +1631,8 @@ final class OfflineTranslationCoordinator: ObservableObject { throw OfflineTranslationConfigurationError.missingVisionModel } let visionModel = requestedVisionModel.isEmpty ? textModel : requestedVisionModel - let textDescriptor = profile.descriptor(for: textModel) - let visionDescriptor = profile.descriptor(for: visionModel) + let textDescriptor = profile.requestDescriptor(for: textModel) + let visionDescriptor = profile.requestDescriptor(for: visionModel) guard AIEndpointResolver.endpointURL(for: textDescriptor.apiProtocol, from: baseURL) != nil, (!requiresVision || AIEndpointResolver.endpointURL(for: visionDescriptor.apiProtocol, from: baseURL) != nil) else { throw OfflineTranslationConfigurationError.invalidBaseURL @@ -1644,6 +1644,7 @@ final class OfflineTranslationCoordinator: ObservableObject { profileID: profile.id, profileName: profile.name, baseURL: baseURL, + interfaceType: profile.interfaceType, apiKey: apiKey, textModel: textModel, visionModel: visionModel, diff --git a/mreaderTests/mreaderTests.swift b/mreaderTests/mreaderTests.swift index a984f087..0e12cb2c 100644 --- a/mreaderTests/mreaderTests.swift +++ b/mreaderTests/mreaderTests.swift @@ -74,6 +74,58 @@ struct mreaderTests { #expect(roundTripped.modelDescriptors.first?.supportsVision == true) } + @Test func aiProviderInterfaceTypeSeparatesCompatibleAndNativeProtocols() { + let descriptor = AIModelDescriptor( + id: "minimax-m3", + apiProtocol: .anthropicMessages, + supportsVision: true + ) + let compatible = AIProviderProfile.normalized( + name: "兼容中转", + baseURL: "https://relay.example/v1", + interfaceType: .compatible, + modelsText: "minimax-m3", + selectedTextModel: "minimax-m3", + selectedVisionModel: "minimax-m3", + modelDescriptors: [descriptor] + ) + let native = AIProviderProfile.normalized( + name: "原生接口", + baseURL: "https://native.example/v1", + interfaceType: .native, + modelsText: "minimax-m3", + selectedTextModel: "minimax-m3", + selectedVisionModel: "minimax-m3", + modelDescriptors: [descriptor] + ) + + #expect(compatible.interfaceType == .compatible) + #expect(compatible.requestDescriptor(for: "minimax-m3").apiProtocol == .openAIChatCompletions) + #expect(native.requestDescriptor(for: "minimax-m3").apiProtocol == .anthropicMessages) + } + + @Test func aiProviderProfileWithoutInterfaceTypePreservesLegacyNativeProtocol() throws { + let profile = AIProviderProfile.normalized( + name: "旧配置", + baseURL: "https://api.example/v1", + interfaceType: .native, + modelsText: "native-model", + selectedTextModel: "native-model", + selectedVisionModel: "native-model", + modelDescriptors: [ + AIModelDescriptor(id: "native-model", apiProtocol: .openAIResponses) + ] + ) + let encoded = try JSONEncoder().encode(profile) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object.removeValue(forKey: "interfaceType") + let legacyData = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(AIProviderProfile.self, from: legacyData) + #expect(decoded.interfaceType == .native) + #expect(decoded.requestDescriptor(for: "native-model").apiProtocol == .openAIResponses) + } + @Test func aiEndpointResolverReplacesEveryKnownEndpointFamily() { let base = "https://api.example.test/v1" #expect(AIEndpointResolver.endpointURL(for: .openAIChatCompletions, from: base)?.absoluteString == "https://api.example.test/v1/chat/completions") @@ -493,6 +545,63 @@ struct mreaderTests { #expect(AITransportRecordingURLProtocol.requestCount() == 2) } + @Test func pageTranslationFallsBackWhenDeepSeekSaysResponseFormatUnavailable() async throws { + let model = "deepseek-unavailable-\(UUID().uuidString)" + let unavailable = Data(#"{"error":{"message":"This response_format type is unavailable now"}}"#.utf8) + let valid = Data(#"{"output_text":"{\"items\":[{\"id\":\"b0\",\"translation\":\"事故\",\"translationLines\":[] }] }"}"#.utf8) + AITransportRecordingURLProtocol.configure(sequence: [ + (400, unavailable), + (400, unavailable), + (200, valid) + ]) + + let result = try await AITranslator.translatePage( + blocks: [TextBlock(text: "じこ", boundingBox: CGRect(x: 0.1, y: 0.1, width: 0.2, height: 0.1))], + apiKey: "secret", + baseURL: "https://api.example.test/v1", + model: model, + target: .simplifiedChinese, + session: aiTransportRecordingSession(), + requestObserver: AITransportRecordingURLProtocol.captureRequest + ) + + #expect(result.translation(for: "b0")?.translation == "事故") + #expect(AITransportRecordingURLProtocol.requestCount() == 3) + let finalRequest = try #require(AITransportRecordingURLProtocol.lastRequest()) + let finalData = try #require(finalRequest.httpBody) + let finalBody = try #require( + JSONSerialization.jsonObject(with: finalData) as? [String: Any] + ) + #expect(finalBody["response_format"] == nil) + + // The prompt-only capability must be cached for this provider/model so + // later pages do not pay the same two HTTP 400 round trips again. + AITransportRecordingURLProtocol.configure(responseData: valid) + _ = try await AITranslator.translatePage( + blocks: [TextBlock(text: "じこ", boundingBox: CGRect(x: 0.1, y: 0.1, width: 0.2, height: 0.1))], + apiKey: "secret", + baseURL: "https://api.example.test/v1", + model: model, + target: .simplifiedChinese, + session: aiTransportRecordingSession(), + requestObserver: AITransportRecordingURLProtocol.captureRequest + ) + #expect(AITransportRecordingURLProtocol.requestCount() == 1) + let cachedRequest = try #require(AITransportRecordingURLProtocol.lastRequest()) + let cachedData = try #require(cachedRequest.httpBody) + let cachedBody = try #require( + JSONSerialization.jsonObject(with: cachedData) as? [String: Any] + ) + #expect(cachedBody["response_format"] == nil) + + let error = AITranslationRequestError.server( + model: model, + statusCode: 400, + message: "This response_format type is unavailable now" + ) + #expect(error.isFormatFailure) + } + @Test func pageHTTP200ProseDowngradesSchemaAndCachesJSONObjectMode() async throws { let model = "semantic-downgrade-\(UUID().uuidString)" let valid = Data(#"{"output_text":"{\"items\":[{\"id\":\"b0\",\"translation\":\"事故\",\"translationLines\":[] }]}"}"#.utf8) @@ -3024,6 +3133,7 @@ struct mreaderTests { #expect(decoded.isICloudMetadataSyncEnabled == true) #expect(decoded.activeAIProviderID == profile.id) #expect(provider.profile.id == profile.id) + #expect(provider.profile.interfaceType == .compatible) #expect(provider.profile.models == ["ocr-model", "vision-model", "shared-model"]) #expect(provider.profile.selectedTextModel == "ocr-model") #expect(provider.profile.selectedVisionModel == "vision-model")