Skip to content
Closed
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
2 changes: 1 addition & 1 deletion Apps/AIME/AIMEInputController+Polish.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ extension AIMEInputController {
guard features.aiRemoteAllowed, let url = URL(string: features.aiBaseURL) else {
return engine.showStatus("未允许把文字发送到自备接口:设置 › AI 助手")
}
provider = OpenAICompatibleProvider(baseURL: url, model: features.aiModel)
provider = OpenAICompatibleProvider(baseURL: url, model: features.aiModel, extraFieldsJSON: features.aiExtraJSON)
} else {
provider = FoundationModelsProvider()
}
Expand Down
97 changes: 93 additions & 4 deletions Apps/AIMESettings/Views/AIAssistantView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ struct AIAssistantView: View {
@AppStorage("ai.provider") private var providerID = "apple"
@AppStorage("ai.baseURL") private var baseURL = "https://api.openai.com/v1"
@AppStorage("ai.model") private var modelName = "gpt-5-mini"
/// Extra JSON object merged into every chat/completions request body.
@AppStorage("ai.extraJSON") private var extraJSON = ""
@State private var apiKey = ""
@State private var availability: AIAvailability?
/// Outcome of the last connection check (OpenAI-compatible endpoint).
Expand Down Expand Up @@ -101,6 +103,58 @@ struct AIAssistantView: View {
}
}

/// Each vendor's way of turning the model's thinking off. One click merges
/// the fields into the custom-JSON box; nothing else is touched.
struct ThinkingPreset {
var title: String
var hint: String
var fields: [String: Any]
}

static let thinkingPresets: [ThinkingPreset] = [
.init(title: "DeepSeek", hint: "DeepSeek:官方 thinking 参数", fields: ["thinking": ["type": "disabled"]]),
.init(title: "Gemini", hint: "Gemini(OpenAI 兼容接口)", fields: ["reasoning_effort": "none"]),
.init(title: "GLM", hint: "智谱 GLM", fields: ["thinking": ["type": "disabled"]]),
.init(title: "Kimi", hint: "Kimi / Moonshot:官方 thinking 参数", fields: ["thinking": ["type": "disabled"]]),
.init(title: "OpenAI", hint: "OpenAI:仅推理模型(如 gpt-5、o 系列)支持;非推理模型带此字段会报错", fields: ["reasoning_effort": "none"]),
.init(title: "OpenRouter", hint: "OpenRouter 统一参数,适用于 Claude 等模型", fields: ["reasoning": ["effort": "none"]]),
.init(title: "Qwen", hint: "通义 Qwen / vLLM 部署", fields: ["enable_thinking": false]),
]

/// Parses the box as a JSON object. nil = empty or not a valid JSON object.
private var extraJSONObject: [String: Any]? {
let trimmed = extraJSON.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil }
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}

/// Live verdict under the box: field count when valid, a hint when not.
@ViewBuilder private var extraJSONStatus: some View {
let trimmed = extraJSON.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
EmptyView()
} else if let object = extraJSONObject {
Label("JSON 有效,将合并 \(object.count) 个字段", systemImage: "checkmark.circle.fill")
.foregroundStyle(Theme.success)
} else {
Label("不是合法的 JSON 对象,不会被发送", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(Theme.warning)
}
}

/// Merges a preset's fields into the box. Merges into the existing object
/// when it parses; non-empty invalid JSON is left alone so the user does
/// not lose a half-written object.
private func mergeFields(_ fields: [String: Any]) {
let trimmed = extraJSON.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, extraJSONObject == nil { return }
var object = extraJSONObject ?? [:]
for (key, value) in fields { object[key] = value }
guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]),
let text = String(data: data, encoding: .utf8) else { return }
extraJSON = text
}

private func saveKeyAndVerify() {
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
Expand All @@ -120,7 +174,7 @@ struct AIAssistantView: View {
}
check = .running
do {
check = .passed(try await OpenAICompatibleProvider(baseURL: url, model: modelName).verify())
check = .passed(try await OpenAICompatibleProvider(baseURL: url, model: modelName, extraFieldsJSON: extraJSON).verify())
} catch let error as AIError {
check = .failed(error.description)
} catch {
Expand All @@ -130,7 +184,7 @@ struct AIAssistantView: View {

private var provider: any AIProvider {
if providerID == "openai", let url = URL(string: baseURL) {
return OpenAICompatibleProvider(baseURL: url, model: modelName)
return OpenAICompatibleProvider(baseURL: url, model: modelName, extraFieldsJSON: extraJSON)
}
return FoundationModelsProvider()
}
Expand Down Expand Up @@ -169,6 +223,40 @@ struct AIAssistantView: View {
Button("保存并验证", action: saveKeyAndVerify)
.disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
VStack(alignment: .leading, spacing: 6) {
Text("自定义请求体")
Text("顶层字段合并进每次请求的 body,可覆盖 temperature、max_tokens 等;model、messages、stream 由程序控制。")
.font(.caption).foregroundStyle(.secondary)
TextEditor(text: $extraJSON)
.font(.caption.monospaced())
.frame(minHeight: 60, maxHeight: 140)
.overlay(alignment: .topLeading) {
if extraJSON.isEmpty {
Text("{\"thinking\": {\"type\": \"disabled\"}}")
.font(.caption.monospaced())
.foregroundStyle(.tertiary)
.padding(.top, 8).padding(.leading, 5)
.allowsHitTesting(false)
}
}
HStack(spacing: 6) {
extraJSONStatus
}
.font(.caption)
}
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text("关闭思考").foregroundStyle(.secondary)
ForEach(Self.thinkingPresets, id: \.title) { preset in
Button(preset.title) { mergeFields(preset.fields) }
.controlSize(.small)
.help(preset.title)
}
}
Text("\(Self.thinkingPresets.map(\.title).joined(separator: "、")) — 按各家官方文档填入对应的关闭思考参数。")
.font(.caption2).foregroundStyle(.tertiary)
}
.font(.caption)
}
LabeledContent("状态") {
if providerID == "openai" {
Expand All @@ -188,13 +276,14 @@ struct AIAssistantView: View {
}
.task(id: providerID) { availability = await provider.availability() }
// Address or model changed: the previous check no longer says anything.
.onChange(of: [baseURL, modelName]) { check = .idle }
.onChange(of: [providerID, baseURL, modelName], initial: true) {
.onChange(of: [baseURL, modelName, extraJSON]) { check = .idle }
.onChange(of: [providerID, baseURL, modelName, extraJSON], initial: true) {
// The input method reads the provider from aime/features.json.
model.updateFeatures {
$0.aiProvider = providerID
$0.aiBaseURL = baseURL
$0.aiModel = modelName
$0.aiExtraJSON = extraJSON
}
}

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and

## [Unreleased]

### Added

- Settings › AI Assistant › OpenAI-compatible endpoint: a new Custom request body field accepts any JSON object whose top-level fields are merged into every request body (model, messages and stream stay under program control). A row of “disable thinking” buttons fills in the right parameter for DeepSeek, Gemini, GLM, Kimi, OpenAI, OpenRouter and Qwen.

## [0.1.7] - 2026-10-09

> Stable release · Developer ID signed · notarized by Apple
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ AIME 每个版本面向用户的变动都记在这里,官网「更新日志」

## [Unreleased]

### 新增

- 设置 › AI 助手 › OpenAI 兼容接口:新增「自定义请求体」,可输入任意 JSON 对象,顶层字段会合并进每次请求的 body(model、messages、stream 由程序控制)。下方一排「关闭思考」按钮可一键填入 DeepSeek、Gemini、GLM、Kimi、OpenAI、OpenRouter、Qwen 各家的关闭思考参数。

## [0.1.7] - 2026-10-09

> 正式版 · Developer ID 签名 · 已通过 Apple 公证
Expand Down
24 changes: 23 additions & 1 deletion Packages/AIMEKit/Sources/AIMEAI/OpenAICompatibleProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ public struct OpenAICompatibleProvider: AIProvider {
public var displayName: String { "OpenAI 兼容接口(\(model))" }
public var baseURL: URL
public var model: String
/// User-supplied JSON object merged into every request body (top-level keys
/// only; `model`, `messages` and `stream` are never overridden). Invalid JSON
/// is ignored.
public let extraFieldsJSON: String
let credentials: CredentialStore
let session: URLSession

public init(baseURL: URL, model: String, keychainAccount: String = "openai-compatible",
credentials: CredentialStore? = nil, session: URLSession = .shared) {
credentials: CredentialStore? = nil, session: URLSession = .shared, extraFieldsJSON: String = "") {
self.baseURL = baseURL
self.model = model
self.extraFieldsJSON = extraFieldsJSON
self.credentials = credentials ?? CredentialStore(account: keychainAccount)
self.session = session
}
Expand Down Expand Up @@ -53,6 +58,9 @@ public struct OpenAICompatibleProvider: AIProvider {
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
var body: [String: Any] = ["model": model, "temperature": temperature, "messages": messages]
if let maxTokens { body["max_tokens"] = maxTokens }
for (key, value) in Self.extraFields(from: extraFieldsJSON) where !Self.reservedKeys.contains(key) {
body[key] = value
}
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let data: Data, response: URLResponse
do {
Expand All @@ -72,6 +80,20 @@ public struct OpenAICompatibleProvider: AIProvider {
return (content, json["model"] as? String)
}

/// Top-level keys the program always sets itself; the user's extra JSON can
/// never override them (`stream` included: the client cannot parse SSE).
static let reservedKeys: Set<String> = ["model", "messages", "stream"]

/// Parses the user's extra-fields JSON. Anything that is not a JSON object
/// (or is invalid) contributes nothing.
static func extraFields(from json: String) -> [String: Any] {
let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [:] }
return object
}

/// Plain-language reason for an HTTP error, so "524" is not all the user sees.
static func explain(status: Int, body: String) -> String {
let detail = body.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
9 changes: 9 additions & 0 deletions Packages/AIMEKit/Sources/AIMECore/Resources/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,15 @@
"storage": "aime/update.json",
"key": "receiveBeta",
"doc": "docs/rime-config-reference.md#更新通道aime-应用设置"
},
{
"id": "ai.extraJSON",
"title": "自定义请求体",
"type": "string",
"default": "",
"storage": "aime/features.json",
"key": "aiExtraJSON",
"doc": "docs/rime-config-reference.md#ai-助手aime-应用设置"
}
]
}
4 changes: 4 additions & 0 deletions Packages/AIMEKit/Sources/AIMECore/UsageStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public struct AIMEFeatures: Sendable, Codable, Equatable {
public var aiProvider = "apple"
public var aiBaseURL = "https://api.openai.com/v1"
public var aiModel = "gpt-5-mini"
/// Extra JSON object merged into every request body (`model`, `messages`
/// and `stream` stay under program control). Empty means no extra fields.
public var aiExtraJSON = ""
/// Explicit consent to send the selected text to `aiBaseURL` when polishing.
public var aiRemoteAllowed = false
/// Small AIME button at the end of the candidate window (opens the quick menu).
Expand Down Expand Up @@ -51,6 +54,7 @@ public struct AIMEFeatures: Sendable, Codable, Equatable {
aiProvider = try container.decodeIfPresent(String.self, forKey: .aiProvider) ?? defaults.aiProvider
aiBaseURL = try container.decodeIfPresent(String.self, forKey: .aiBaseURL) ?? defaults.aiBaseURL
aiModel = try container.decodeIfPresent(String.self, forKey: .aiModel) ?? defaults.aiModel
aiExtraJSON = try container.decodeIfPresent(String.self, forKey: .aiExtraJSON) ?? defaults.aiExtraJSON
aiRemoteAllowed = try container.decodeIfPresent(Bool.self, forKey: .aiRemoteAllowed) ?? defaults.aiRemoteAllowed
panelMenuButton = try container.decodeIfPresent(Bool.self, forKey: .panelMenuButton) ?? defaults.panelMenuButton
menuHoldKey = try container.decodeIfPresent(ModifierHold.Key.self, forKey: .menuHoldKey) ?? defaults.menuHoldKey
Expand Down
66 changes: 66 additions & 0 deletions Packages/AIMEKit/Tests/AIMEAITests/AITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@ import Testing
@testable import AIMEAI
@testable import AIMECore

/// Captures the request body of the next URLSession request and answers with a
/// canned chat/completions response, so tests can inspect what would be sent.
final class BodyCapturingProtocol: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var lastBody: [String: Any]?

override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
if let stream = request.httpBodyStream,
let data = try? Data(reading: stream) {
Self.lastBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
let json = #"{"choices":[{"message":{"content":"OK"}}],"model":"m"}"#.data(using: .utf8)!
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: json)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}

private extension Data {
init(reading stream: InputStream) throws {
stream.open()
defer { stream.close() }
self.init()
var buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable {
let n = stream.read(&buffer, maxLength: buffer.count)
if n < 0 { throw stream.streamError ?? CocoaError(.coderReadCorrupt) }
if n == 0 { break }
append(contentsOf: buffer[0..<n])
}
}
}

struct MockProvider: AIProvider {
let id = "mock"
let displayName = "Mock"
Expand Down Expand Up @@ -79,6 +114,7 @@ struct PolishTests {
@Test func featuresDecodeWithMissingKeys() throws {
let features = try JSONDecoder().decode(AIMEFeatures.self, from: Data(#"{"usageStats":true}"#.utf8))
#expect(features.usageStats && !features.aiPolish && !features.aiRemoteAllowed && features.aiProvider == "apple")
#expect(features.aiExtraJSON.isEmpty)
}
}

Expand Down Expand Up @@ -130,6 +166,36 @@ struct CredentialAndErrorTests {
#expect(AIError.http(524, OpenAICompatibleProvider.explain(status: 524, body: "")).description.hasPrefix("HTTP 524:"))
}

@Test func extraFieldsParseOnlyJSONObjects() {
#expect(OpenAICompatibleProvider.extraFields(from: "").isEmpty)
#expect(OpenAICompatibleProvider.extraFields(from: " ").isEmpty)
#expect(OpenAICompatibleProvider.extraFields(from: "not json").isEmpty)
#expect(OpenAICompatibleProvider.extraFields(from: "[1,2]").isEmpty)
let fields = OpenAICompatibleProvider.extraFields(from: #"{"enable_thinking":false,"n":1}"#)
#expect(fields["enable_thinking"] as? Bool == false && fields["n"] as? Int == 1)
}

@Test func reservedKeysAreNeverOverridden() async throws {
// A user JSON carrying model/messages/stream must not change what the
// program sends: `stream` in particular would switch the response to
// SSE, which the client cannot parse.
let url = FileManager.default.temporaryDirectory.appendingPathComponent("aime-cred-\(UUID().uuidString)/credentials.json")
let store = CredentialStore(account: "openai-compatible", fileURL: url)
#expect(store.write("sk-test"))
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [BodyCapturingProtocol.self]
let provider = OpenAICompatibleProvider(
baseURL: URL(string: "https://example.invalid/v1")!, model: "real-model",
credentials: store, session: URLSession(configuration: config),
extraFieldsJSON: #"{"model":"fake","messages":[],"stream":true,"temperature":0.9}"#)
_ = try? await provider.verify()
let sentBody = BodyCapturingProtocol.lastBody
#expect(sentBody?["model"] as? String == "real-model")
#expect((sentBody?["messages"] as? [[String: String]])?.first?["role"] == "user")
#expect(sentBody?["stream"] == nil)
#expect(sentBody?["temperature"] as? Double == 0.9)
}

@Test func missingKeyFailsBeforeAnyRequest() async {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("aime-cred-\(UUID().uuidString)/credentials.json")
let provider = OpenAICompatibleProvider(baseURL: URL(string: "https://example.invalid/v1")!, model: "m",
Expand Down
8 changes: 8 additions & 0 deletions docs/rime-config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,3 +692,11 @@ catalog: ascii_state.scope
这是应用偏好,不写 RIME YAML;catalog 的 `appSettings` 登记此类非 RIME 设置,`settings` 仍仅用于 RIME 补丁。

catalog: update.receiveBeta


## AI 助手(AIME 应用设置)

设置 › AI 助手 › OpenAI 兼容接口的「自定义请求体」存于 `aime/features.json` 的 `aiExtraJSON` 字段。
用户填入的 JSON 对象的顶层字段会合并进每次 chat/completions 请求的 body,可覆盖 `temperature`、`max_tokens` 等参数;`model`、`messages`、`stream` 由程序控制,不会被覆盖。非法 JSON 不发送。

catalog: ai.extraJSON