From 32f4f4091c07dcdd3d4340f3cf0e11817d624bf4 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:29:58 +0800 Subject: [PATCH 01/12] Add compatible and native AI interface types --- mreader/AIModelProtocol.swift | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/mreader/AIModelProtocol.swift b/mreader/AIModelProtocol.swift index 1c0f8cf9..7fa1fc8a 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, 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. From 501044cdc4330b67befc6e280f83af0fe4dfe034 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:30:26 +0800 Subject: [PATCH 02/12] Persist AI provider interface type --- mreader/AIProviderStore.swift | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) 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) ) } From 1d820f8726876a8293f57bf29a2d618219a3ed18 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:30:40 +0800 Subject: [PATCH 03/12] Fallback when DeepSeek response_format is unavailable --- mreader/AITranslator.swift | 31 ++----------------------------- 1 file changed, 2 insertions(+), 29 deletions(-) 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( From 7a6d6327e3b26a8b15a146b5cda36c7c121b1c21 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:31:13 +0800 Subject: [PATCH 04/12] Add selectable compatible and native provider modes --- mreader/AIProviderSettingsView.swift | 52 +++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/mreader/AIProviderSettingsView.swift b/mreader/AIProviderSettingsView.swift index 54047655..4d0fe785 100644 --- a/mreader/AIProviderSettingsView.swift +++ b/mreader/AIProviderSettingsView.swift @@ -291,6 +291,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 +319,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 +345,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 +459,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 +493,7 @@ private struct AIProviderEditorView: View { id: profileID, name: name, baseURL: baseURL, + interfaceType: interfaceType, modelsText: modelsText, selectedTextModel: selectedTextModel, selectedVisionModel: selectedVisionModel, @@ -523,8 +535,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 +600,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 +662,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 +681,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 +722,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 +730,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 +746,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 协议") From 481a7aef76f52d81e468a9ce2732ea2da0724427 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:31:43 +0800 Subject: [PATCH 05/12] Cover interface modes and DeepSeek format fallback --- mreaderTests/mreaderTests.swift | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/mreaderTests/mreaderTests.swift b/mreaderTests/mreaderTests.swift index a984f087..6e3b2bb3 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,42 @@ 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.deepseek.example/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 finalBody = try #require( + JSONSerialization.jsonObject(with: try #require(finalRequest.httpBody)) as? [String: Any] + ) + #expect(finalBody["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) From 253a06d4bc43abaefb1eae42e1ac36361f7255b3 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:32:52 +0800 Subject: [PATCH 06/12] Honor provider interface mode for offline translation --- mreader/OfflineTranslationCoordinator.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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, From a5b43dc99f9547e49a630c6b6988464298ed2fd0 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:33:50 +0800 Subject: [PATCH 07/12] Make AI interface type hashable --- mreader/AIModelProtocol.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mreader/AIModelProtocol.swift b/mreader/AIModelProtocol.swift index 7fa1fc8a..66cfd2c0 100644 --- a/mreader/AIModelProtocol.swift +++ b/mreader/AIModelProtocol.swift @@ -18,7 +18,7 @@ nonisolated enum AIAPIProtocol: String, Codable, CaseIterable, Sendable { } } -nonisolated enum AIProviderInterfaceType: String, Codable, CaseIterable, Sendable { +nonisolated enum AIProviderInterfaceType: String, Codable, CaseIterable, Hashable, Sendable { case compatible case native From b5a6be5a023cff5b3a56c8fca3fa73331fbab1f1 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:35:04 +0800 Subject: [PATCH 08/12] Verify DeepSeek fallback capability cache --- mreaderTests/mreaderTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/mreaderTests/mreaderTests.swift b/mreaderTests/mreaderTests.swift index 6e3b2bb3..b2cc2140 100644 --- a/mreaderTests/mreaderTests.swift +++ b/mreaderTests/mreaderTests.swift @@ -573,6 +573,25 @@ struct mreaderTests { ) #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.deepseek.example/v1", + model: model, + target: .simplifiedChinese, + session: aiTransportRecordingSession(), + requestObserver: AITransportRecordingURLProtocol.captureRequest + ) + #expect(AITransportRecordingURLProtocol.requestCount() == 1) + let cachedRequest = try #require(AITransportRecordingURLProtocol.lastRequest()) + let cachedBody = try #require( + JSONSerialization.jsonObject(with: try #require(cachedRequest.httpBody)) as? [String: Any] + ) + #expect(cachedBody["response_format"] == nil) + let error = AITranslationRequestError.server( model: model, statusCode: 400, From 4fabb8636fbe307bf18e112628d8fe17a053c7b5 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:35:45 +0800 Subject: [PATCH 09/12] Show provider interface type in settings list --- mreader/AIProviderSettingsView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/mreader/AIProviderSettingsView.swift b/mreader/AIProviderSettingsView.swift index 4d0fe785..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) From bc6e8f8be02533090040fa1b9ed5a19e5a346b4c Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:35:54 +0800 Subject: [PATCH 10/12] Cover AI interface type in settings backup --- mreaderTests/mreaderTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/mreaderTests/mreaderTests.swift b/mreaderTests/mreaderTests.swift index b2cc2140..910abc13 100644 --- a/mreaderTests/mreaderTests.swift +++ b/mreaderTests/mreaderTests.swift @@ -3131,6 +3131,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") From feba94f3ecf7d958027289a4e328a239991e0c50 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:39:31 +0800 Subject: [PATCH 11/12] Fix Swift Testing require macro expansion --- mreaderTests/mreaderTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mreaderTests/mreaderTests.swift b/mreaderTests/mreaderTests.swift index 910abc13..3d379c6c 100644 --- a/mreaderTests/mreaderTests.swift +++ b/mreaderTests/mreaderTests.swift @@ -568,8 +568,9 @@ struct mreaderTests { #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: try #require(finalRequest.httpBody)) as? [String: Any] + JSONSerialization.jsonObject(with: finalData) as? [String: Any] ) #expect(finalBody["response_format"] == nil) @@ -587,8 +588,9 @@ struct mreaderTests { ) #expect(AITransportRecordingURLProtocol.requestCount() == 1) let cachedRequest = try #require(AITransportRecordingURLProtocol.lastRequest()) + let cachedData = try #require(cachedRequest.httpBody) let cachedBody = try #require( - JSONSerialization.jsonObject(with: try #require(cachedRequest.httpBody)) as? [String: Any] + JSONSerialization.jsonObject(with: cachedData) as? [String: Any] ) #expect(cachedBody["response_format"] == nil) From b4b4999922d637adfceed0b19d0a0a0429a50214 Mon Sep 17 00:00:00 2001 From: zhengyk <53221608+zyk1172@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:18:49 +0800 Subject: [PATCH 12/12] Fix DeepSeek fallback test to use mocked API host --- mreaderTests/mreaderTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mreaderTests/mreaderTests.swift b/mreaderTests/mreaderTests.swift index 3d379c6c..0e12cb2c 100644 --- a/mreaderTests/mreaderTests.swift +++ b/mreaderTests/mreaderTests.swift @@ -558,7 +558,7 @@ struct mreaderTests { 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.deepseek.example/v1", + baseURL: "https://api.example.test/v1", model: model, target: .simplifiedChinese, session: aiTransportRecordingSession(), @@ -580,7 +580,7 @@ struct mreaderTests { _ = 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.deepseek.example/v1", + baseURL: "https://api.example.test/v1", model: model, target: .simplifiedChinese, session: aiTransportRecordingSession(),