diff --git a/Sources/Conduit/Providers/OpenAI/OpenAIProvider+Helpers.swift b/Sources/Conduit/Providers/OpenAI/OpenAIProvider+Helpers.swift index f6c8c11..b04ec03 100644 --- a/Sources/Conduit/Providers/OpenAI/OpenAIProvider+Helpers.swift +++ b/Sources/Conduit/Providers/OpenAI/OpenAIProvider+Helpers.swift @@ -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 { @@ -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 @@ -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] = [:]