Skip to content
Merged
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
54 changes: 41 additions & 13 deletions Sources/Conduit/Providers/OpenAI/OpenAIProvider+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,22 +125,33 @@ extension OpenAIProvider {

// Add generation config
if let maxTokens = config.maxTokens {
body["max_tokens"] = maxTokens
// o-series reasoning models and gpt-5+ reject "max_tokens"; use the newer key.
let tokenKey = requiresMaxCompletionTokens(model: model) ? "max_completion_tokens" : "max_tokens"
body[tokenKey] = maxTokens
}

body["temperature"] = config.temperature.openAIJSONNumber
body["top_p"] = config.topP.openAIJSONNumber
// o-series and gpt-5+ only accept default values for these sampling params; omit them entirely.
if requiresMaxCompletionTokens(model: model) {
let defaults = GenerateConfig.default
if config.temperature != defaults.temperature || config.topP != defaults.topP
|| config.frequencyPenalty != defaults.frequencyPenalty || config.presencePenalty != defaults.presencePenalty {
logger.debug("Model '\(model.rawValue)' does not support custom sampling parameters; temperature, top_p, and penalties will be ignored.")
}
} else {
body["temperature"] = config.temperature.openAIJSONNumber
body["top_p"] = config.topP.openAIJSONNumber

if let topK = config.topK {
body["top_k"] = topK
}
if let topK = config.topK {
body["top_k"] = topK
}

if config.frequencyPenalty != 0 {
body["frequency_penalty"] = config.frequencyPenalty.openAIJSONNumber
}
if config.frequencyPenalty != 0 {
body["frequency_penalty"] = config.frequencyPenalty.openAIJSONNumber
}

if config.presencePenalty != 0 {
body["presence_penalty"] = config.presencePenalty.openAIJSONNumber
if config.presencePenalty != 0 {
body["presence_penalty"] = config.presencePenalty.openAIJSONNumber
}
}

if !config.stopSequences.isEmpty {
Expand Down Expand Up @@ -256,8 +267,16 @@ extension OpenAIProvider {
if let maxTokens = config.maxTokens {
body["max_output_tokens"] = maxTokens
}
body["temperature"] = config.temperature.openAIJSONNumber
body["top_p"] = config.topP.openAIJSONNumber

if requiresMaxCompletionTokens(model: model) {
let defaults = GenerateConfig.default
if config.temperature != defaults.temperature || config.topP != defaults.topP {
logger.debug("Model '\(model.rawValue)' does not support custom sampling parameters; temperature and top_p will be ignored.")
}
} else {
body["temperature"] = config.temperature.openAIJSONNumber
body["top_p"] = config.topP.openAIJSONNumber
}

if !config.stopSequences.isEmpty {
body["stop"] = config.stopSequences
Expand Down Expand Up @@ -576,6 +595,15 @@ extension OpenAIProvider {
}
}

/// Returns true for models (o-series, gpt-5+) that require `max_completion_tokens`
/// and reject non-default sampling params (temperature, top_p, penalties).
private nonisolated func requiresMaxCompletionTokens(model: ModelIdentifier) -> Bool {
let raw = model.rawValue
// Strip optional "provider/" prefix used by OpenRouter (e.g. "openai/gpt-5-mini" → "gpt-5-mini").
let id = raw.firstIndex(of: "/").map { String(raw[raw.index(after: $0)...]) } ?? raw
return id.hasPrefix("o1") || id.hasPrefix("o3") || id.hasPrefix("o4") || id.hasPrefix("gpt-5")
}

/// Serializes reasoning configuration.
private nonisolated func serializeReasoningConfig(_ config: ReasoningConfig) -> [String: Any] {
var result: [String: Any] = [:]
Expand Down