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
63 changes: 63 additions & 0 deletions mreader/AIModelProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
63 changes: 51 additions & 12 deletions mreader/AIProviderSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -482,6 +498,7 @@ private struct AIProviderEditorView: View {
id: profileID,
name: name,
baseURL: baseURL,
interfaceType: interfaceType,
modelsText: modelsText,
selectedTextModel: selectedTextModel,
selectedVisionModel: selectedVisionModel,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -698,17 +727,20 @@ private struct AIModelDescriptorEditorView: View {
@Environment(\.dismiss) private var dismiss

let modelID: String
let interfaceType: AIProviderInterfaceType
let onSave: (AIModelDescriptor) -> Void

@State private var apiProtocol: AIAPIProtocol
@State private var visionCapability: AIModelVisionCapability

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(
Expand All @@ -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 协议")
Expand Down
36 changes: 34 additions & 2 deletions mreader/AIProviderStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 翻译云端兜底。
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -183,6 +212,7 @@ nonisolated struct AIActiveConfiguration: Sendable, Equatable {
profileID: UUID,
profileName: String,
baseURL: String,
interfaceType: AIProviderInterfaceType = .native,
apiKey: String,
textModel: String,
visionModel: String,
Expand All @@ -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
Expand Down Expand Up @@ -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)
)
}

Expand Down
Loading
Loading