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
72 changes: 72 additions & 0 deletions swift/Sources/CoreAILMCommon/ReasoningEffort.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions swift/Sources/CoreAILMCommon/ServerAPITypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
24 changes: 18 additions & 6 deletions swift/Sources/Tools/llm-server/ChatHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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]

Expand All @@ -586,15 +596,15 @@ 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
}
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)
}

Expand All @@ -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")
Expand Down
24 changes: 21 additions & 3 deletions swift/Sources/Tools/llm-server/LLMServerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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 {
Expand Down Expand Up @@ -186,14 +201,17 @@ 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,
defaultTemperature: temperature,
defaultTopP: topP,
defaultTopK: topK,
defaultMinP: minP,
noThinking: noThinking,
defaultReasoningEffort: resolvedReasoningDefault,
supportsLogprobs: supportsLogprobs,
maxContextLength: bundle.maxContextLength,
vocabSize: bundle.vocabSize,
Expand All @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion swift/Sources/Tools/llm-server/ServerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
76 changes: 76 additions & 0 deletions swift/Tests/CoreAILMCommonTests/ReasoningEffortTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}