diff --git a/swift/Sources/CoreAILMCommon/ReasoningEffort.swift b/swift/Sources/CoreAILMCommon/ReasoningEffort.swift new file mode 100644 index 00000000..3b509143 --- /dev/null +++ b/swift/Sources/CoreAILMCommon/ReasoningEffort.swift @@ -0,0 +1,72 @@ +// Copyright 2026 Apple Inc. +// +// Use of this source code is governed by a BSD-3-clause license that can +// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause + +import Foundation + +/// Thrown when server flags request contradictory reasoning defaults. +public enum ReasoningEffortError: Error, CustomStringConvertible { + /// `--no-thinking` was combined with a non-`none` `--default-reasoning-effort`. + case contradiction(default: String) + + public var description: String { + switch self { + case .contradiction(let value): + return "--no-thinking conflicts with --default-reasoning-effort \(value); --no-thinking means none" + } + } +} + +/// Resolves a reasoning-effort value into chat-template keyword arguments, which are then passed to +/// `applyChatTemplate(additionalContext:)`. +/// +/// Reasoning models expose their thinking budget through different chat-template variables (for +/// example `reasoning_effort` or a boolean `enable_thinking`). Binding the canonical value to each +/// known variable lets one request field drive them all: a chat template reads only the variables +/// it references, so setting the others alongside is safe. +public enum ReasoningEffort { + /// Canonical value that requests no reasoning. + public static let none = "none" + + /// Maps a canonical effort value to chat-template keyword arguments. + /// + /// - `nil` or empty returns an empty dictionary, so the template keeps its own default. + /// - `"none"` sets `enable_thinking` to `false` for templates that support disabling reasoning. + /// - any level (for example `low`, `medium`, `high`) binds the level to `reasoning_effort` and + /// sets `enable_thinking` to `true`. + public static func templateContext(_ effort: String?) -> [String: any Sendable] { + guard let value = effort?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return [:] + } + if value.lowercased() == none { + return ["enable_thinking": false] + } + return [ + "reasoning_effort": value, + "enable_thinking": true, + ] + } + + /// Resolves the effort for a request: the per-request value takes precedence, then the server + /// default, then `nil` (leaving the template default in place). + public static func resolve(request: String?, default defaultEffort: String?) -> String? { + request ?? defaultEffort + } + + /// Whether a resolved effort disables model thinking (canonical `none`). Drives both the + /// `enable_thinking:false` template var and the legacy `/no_think` literal injection. + public static func disablesThinking(_ effort: String?) -> Bool { + effort?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == none + } + + /// Folds the `--no-thinking` alias into the server's `--default-reasoning-effort`. `--no-thinking` + /// means `none`; combining it with a non-`none` default is a contradiction. + public static func resolveDefault(defaultReasoningEffort: String?, noThinking: Bool) throws -> String? { + guard noThinking else { return defaultReasoningEffort } + if let value = defaultReasoningEffort, !disablesThinking(value) { + throw ReasoningEffortError.contradiction(default: value) + } + return none + } +} diff --git a/swift/Sources/CoreAILMCommon/ServerAPITypes.swift b/swift/Sources/CoreAILMCommon/ServerAPITypes.swift index 0e62f4de..09360dce 100644 --- a/swift/Sources/CoreAILMCommon/ServerAPITypes.swift +++ b/swift/Sources/CoreAILMCommon/ServerAPITypes.swift @@ -23,6 +23,9 @@ public struct ChatCompletionRequest: Decodable, Sendable { public let toolChoice: ToolChoice? public let parallelToolCalls: Bool? public let raw: Bool? + /// Reasoning-effort control, OpenAI-compatible. Canonical values are none, low, medium, high; + /// other values pass through to the model's chat template. + public let reasoningEffort: String? enum CodingKeys: String, CodingKey { case model, messages, temperature, stream, stop, tools, raw, seed @@ -33,6 +36,7 @@ public struct ChatCompletionRequest: Decodable, Sendable { case responseFormat = "response_format" case toolChoice = "tool_choice" case parallelToolCalls = "parallel_tool_calls" + case reasoningEffort = "reasoning_effort" } public init(from decoder: Decoder) throws { @@ -51,6 +55,7 @@ public struct ChatCompletionRequest: Decodable, Sendable { toolChoice = try container.decodeIfPresent(ToolChoice.self, forKey: .toolChoice) parallelToolCalls = try container.decodeIfPresent(Bool.self, forKey: .parallelToolCalls) raw = try container.decodeIfPresent(Bool.self, forKey: .raw) + reasoningEffort = try container.decodeIfPresent(String.self, forKey: .reasoningEffort) if let arr = try? container.decode([String].self, forKey: .stop) { stop = arr diff --git a/swift/Sources/Tools/llm-server/ChatHandler.swift b/swift/Sources/Tools/llm-server/ChatHandler.swift index 78994e63..5db89fcd 100644 --- a/swift/Sources/Tools/llm-server/ChatHandler.swift +++ b/swift/Sources/Tools/llm-server/ChatHandler.swift @@ -187,7 +187,10 @@ private func handleNonStreamingRequest(chatRequest: ChatCompletionRequest, state seed: chatRequest.seed ) - let promptTokens = tokenizeMessages(chatRequest.messages, tools: chatRequest.tools, state: state) + let reasoningEffort = ReasoningEffort.resolve( + request: chatRequest.reasoningEffort, default: state.config.defaultReasoningEffort) + let promptTokens = tokenizeMessages( + chatRequest.messages, tools: chatRequest.tools, reasoningEffort: reasoningEffort, state: state) let stopSequences = buildStopSequences(from: chatRequest, state: state) let input: Input = .tokens(promptTokens) @@ -361,7 +364,10 @@ private func handleStreamingRequest( seed: chatRequest.seed ) - let promptTokens = tokenizeMessages(chatRequest.messages, tools: chatRequest.tools, state: state) + let reasoningEffort = ReasoningEffort.resolve( + request: chatRequest.reasoningEffort, default: state.config.defaultReasoningEffort) + let promptTokens = tokenizeMessages( + chatRequest.messages, tools: chatRequest.tools, reasoningEffort: reasoningEffort, state: state) let stopSequences = buildStopSequences(from: chatRequest, state: state) let input: Input = .tokens(promptTokens) @@ -562,9 +568,13 @@ private func handleStreamingRequest( // MARK: - Helpers private func tokenizeMessages( - _ messages: [ChatMessage], tools: [ToolDefinition]? = nil, state: ServerState + _ messages: [ChatMessage], tools: [ToolDefinition]? = nil, + reasoningEffort: String? = nil, state: ServerState ) -> [Int] { var templateMessages: [[String: any Sendable]] = [] + // Resolved effort is the single source of truth: `none` also injects the legacy `/no_think` + // literal for models (e.g. Qwen3) that honor it in the system prompt. + let noThink = ReasoningEffort.disablesThinking(reasoningEffort) for msg in messages { var dict: [String: any Sendable] = ["role": msg.role] @@ -586,7 +596,7 @@ private func tokenizeMessages( dict["content"] = msg.content.textContent } else { var content = msg.content.textContent - if msg.role == "system" && state.config.noThinking { + if msg.role == "system" && noThink { content += "\n/no_think" } dict["content"] = content @@ -594,7 +604,7 @@ private func tokenizeMessages( templateMessages.append(dict) } - if state.config.noThinking && !messages.contains(where: { $0.role == "system" }) { + if noThink && !messages.contains(where: { $0.role == "system" }) { templateMessages.insert(["role": "system", "content": "/no_think"], at: 0) } @@ -614,8 +624,10 @@ private func tokenizeMessages( } do { + let additionalContext = ReasoningEffort.templateContext(reasoningEffort) let tokens = try state.tokenizer.applyChatTemplate( - messages: templateMessages, tools: toolSpecs) + messages: templateMessages, tools: toolSpecs, + additionalContext: additionalContext.isEmpty ? nil : additionalContext) return tokens } catch { CLILogger.log("applyChatTemplate failed: \(error)", component: "Server") diff --git a/swift/Sources/Tools/llm-server/LLMServerMain.swift b/swift/Sources/Tools/llm-server/LLMServerMain.swift index 41cd72fb..e6affd3c 100644 --- a/swift/Sources/Tools/llm-server/LLMServerMain.swift +++ b/swift/Sources/Tools/llm-server/LLMServerMain.swift @@ -68,9 +68,18 @@ struct LLMServer: AsyncParsableCommand { @Option(name: .customLong("max-queue-depth"), help: "Max requests queued before returning 429 (default: 16)") var maxQueueDepth: Int = 16 - @Flag(name: .customLong("no-thinking"), help: "Disable thinking/reasoning (appends /no_think or sets template)") + @Flag( + name: .customLong("no-thinking"), + help: "Disable thinking/reasoning (alias for --default-reasoning-effort none)" + ) var noThinking: Bool = false + @Option( + name: .customLong("default-reasoning-effort"), + help: "Default reasoning_effort when a request omits it (none, low, medium, high)." + ) + var defaultReasoningEffort: String? + @Flag( name: .customLong("clear-coreai-cache"), help: "Clear Core AI cached specialization for this model before loading (forces re-specialization)" @@ -84,6 +93,12 @@ struct LLMServer: AsyncParsableCommand { guard maxQueueDepth >= 0 else { throw ValidationError("--max-queue-depth must be >= 0 (got \(maxQueueDepth))") } + do { + _ = try ReasoningEffort.resolveDefault( + defaultReasoningEffort: defaultReasoningEffort, noThinking: noThinking) + } catch { + throw ValidationError("\(error)") + } } func run() async throws { @@ -186,6 +201,9 @@ struct LLMServer: AsyncParsableCommand { print(" done in \(String(format: "%.3f", prepareElapsed))s\(cacheSuffix)\n") } + let resolvedReasoningDefault = try ReasoningEffort.resolveDefault( + defaultReasoningEffort: defaultReasoningEffort, noThinking: noThinking) + let config = ServerConfig( modelName: modelName, defaultMaxTokens: maxTokens, @@ -193,7 +211,7 @@ struct LLMServer: AsyncParsableCommand { defaultTopP: topP, defaultTopK: topK, defaultMinP: minP, - noThinking: noThinking, + defaultReasoningEffort: resolvedReasoningDefault, supportsLogprobs: supportsLogprobs, maxContextLength: bundle.maxContextLength, vocabSize: bundle.vocabSize, @@ -212,7 +230,7 @@ struct LLMServer: AsyncParsableCommand { print(" Engine: \(type(of: engine))") print(" Logprobs: \(supportsLogprobs ? "supported" : "not supported (use --variant coreai-sequential)")") print(" Context: \(bundle.maxContextLength) tokens") - print(" No-thinking: \(noThinking)") + print(" Default reasoning effort: \(config.defaultReasoningEffort ?? "template default")") print(" Max queue depth: \(maxQueueDepth)") let topKStr = topK.map { "\($0)" } ?? "nil" let topPStr = topP.map { "\($0)" } ?? "nil" diff --git a/swift/Sources/Tools/llm-server/ServerState.swift b/swift/Sources/Tools/llm-server/ServerState.swift index 03b448f1..0283c90b 100644 --- a/swift/Sources/Tools/llm-server/ServerState.swift +++ b/swift/Sources/Tools/llm-server/ServerState.swift @@ -19,7 +19,10 @@ struct ServerConfig: Sendable { let defaultTopP: Double? let defaultTopK: Int? let defaultMinP: Double? - let noThinking: Bool + /// Default `reasoning_effort` applied when a request omits it. `nil` leaves the template default. + /// `--no-thinking` folds into this as `none`, which drives both `enable_thinking:false` and the + /// legacy `/no_think` literal injection. + let defaultReasoningEffort: String? let supportsLogprobs: Bool let maxContextLength: Int let vocabSize: Int? diff --git a/swift/Tests/CoreAILMCommonTests/ReasoningEffortTests.swift b/swift/Tests/CoreAILMCommonTests/ReasoningEffortTests.swift new file mode 100644 index 00000000..67608a16 --- /dev/null +++ b/swift/Tests/CoreAILMCommonTests/ReasoningEffortTests.swift @@ -0,0 +1,76 @@ +// Copyright 2026 Apple Inc. +// +// Use of this source code is governed by a BSD-3-clause license that can +// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause + +import Foundation +import Testing + +@testable import CoreAILMCommon + +@Suite("Reasoning Effort") +struct ReasoningEffortTests { + @Test("An absent or empty effort injects no template context") + func emptyInjectsNothing() { + #expect(ReasoningEffort.templateContext(nil).isEmpty) + #expect(ReasoningEffort.templateContext("").isEmpty) + } + + @Test("none disables thinking") + func noneDisablesThinking() { + let context = ReasoningEffort.templateContext("none") + #expect((context["enable_thinking"] as? Bool) == false) + } + + @Test("A level binds each reasoning variable") + func levelBindsVariables() { + for level in ["low", "medium", "high"] { + let context = ReasoningEffort.templateContext(level) + #expect((context["reasoning_effort"] as? String) == level) + #expect((context["enable_thinking"] as? Bool) == true) + #expect(context["reasoning_strength"] == nil) + } + } + + @Test("A request value takes precedence over the default") + func precedence() { + #expect(ReasoningEffort.resolve(request: "high", default: "low") == "high") + #expect(ReasoningEffort.resolve(request: nil, default: "low") == "low") + #expect(ReasoningEffort.resolve(request: nil, default: nil) == nil) + } + + @Test("disablesThinking is true only for the canonical none") + func disablesThinking() { + #expect(ReasoningEffort.disablesThinking("none")) + #expect(ReasoningEffort.disablesThinking("None")) + #expect(ReasoningEffort.disablesThinking(" none ")) + #expect(!ReasoningEffort.disablesThinking("low")) + #expect(!ReasoningEffort.disablesThinking(nil)) + #expect(!ReasoningEffort.disablesThinking("")) + } + + @Test("--no-thinking folds into the reasoning default as none") + func resolveDefaultNoThinking() throws { + #expect(try ReasoningEffort.resolveDefault(defaultReasoningEffort: nil, noThinking: true) == "none") + #expect(try ReasoningEffort.resolveDefault(defaultReasoningEffort: nil, noThinking: false) == nil) + #expect(try ReasoningEffort.resolveDefault(defaultReasoningEffort: "low", noThinking: false) == "low") + // --no-thinking plus an explicit none default is consistent, not a conflict. + #expect(try ReasoningEffort.resolveDefault(defaultReasoningEffort: "none", noThinking: true) == "none") + } + + @Test("--no-thinking with a non-none default is rejected") + func resolveDefaultContradiction() { + #expect(throws: ReasoningEffortError.self) { + try ReasoningEffort.resolveDefault(defaultReasoningEffort: "low", noThinking: true) + } + } + + @Test("reasoning_effort decodes from a chat completion request") + func requestDecodes() throws { + let json = """ + {"messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high"} + """.data(using: .utf8)! + let request = try JSONDecoder().decode(ChatCompletionRequest.self, from: json) + #expect(request.reasoningEffort == "high") + } +}