diff --git a/Sources/LLM/LLMProvider.swift b/Sources/LLM/LLMProvider.swift index 9551d78..83b38f6 100644 --- a/Sources/LLM/LLMProvider.swift +++ b/Sources/LLM/LLMProvider.swift @@ -102,10 +102,14 @@ final class HTTPProviderClient: LLMProviderClient { let userMessage = ChatQuery.ChatCompletionMessageParam(role: .user, content: request.userPrompt) else { throw LLMProviderError.malformedJSON } + // Reasoning models (gpt-5+, o-series) 400 on `temperature`; send the + // lowest reasoning effort instead so completions stay fast and cheap. + let isReasoning = OpenAIModelTraits.isReasoningModel(request.model) let query = ChatQuery( messages: [systemMessage, userMessage], model: request.model, - temperature: request.temperature, + reasoningEffort: OpenAIModelTraits.reasoningEffort(for: request.model).map { .customValue($0) }, + temperature: isReasoning ? nil : request.temperature, stream: true, streamOptions: ChatQuery.StreamOptions(includeUsage: true) ) @@ -307,16 +311,73 @@ enum ProviderRequestFactory { "temperature": request.temperature, ] case .openAICompatible: - return [ + var body: [String: Any] = [ "model": request.model, - "max_tokens": request.maxTokens, "messages": [ ["role": "system", "content": request.systemPrompt], ["role": "user", "content": request.userPrompt], ], - "temperature": request.temperature, ] + if OpenAIModelTraits.isReasoningModel(request.model) { + body["max_completion_tokens"] = request.maxTokens + body["reasoning_effort"] = OpenAIModelTraits.reasoningEffort(for: request.model) + } else { + body["max_tokens"] = request.maxTokens + body["temperature"] = request.temperature + } + return body + } + } +} + +/// Request-shape differences between OpenAI model families. Reasoning models +/// (o-series, gpt-5 and later such as gpt-6-luna) reject sampling knobs like +/// `temperature` with HTTP 400 and take `reasoning_effort` instead. Detected by +/// model id, so it also covers OpenAI models routed through OpenRouter +/// ("openai/gpt-6-luna") and leaves other OpenAI-compatible vendors untouched. +enum OpenAIModelTraits { + static func isReasoningModel(_ model: String) -> Bool { + let id = normalized(model) + if isOSeries(id) { return true } + guard let version = gptVersion(id), version.major >= 5 else { return false } + // "gpt-5-chat-latest" is the non-reasoning chat snapshot. + return !id.contains("-chat") + } + + /// The lowest-latency effort the model accepts: shell completions want a + /// quick answer, not deliberation. `nil` for non-reasoning models. + static func reasoningEffort(for model: String) -> String? { + guard isReasoningModel(model) else { return nil } + let id = normalized(model) + if isOSeries(id) { return "low" } + guard let version = gptVersion(id) else { return nil } + // gpt-5.1 and later accept "none"; the original gpt-5 family bottoms out at "minimal". + if version.major == 5 && version.minor == 0 { return "minimal" } + return "none" + } + + private static func normalized(_ model: String) -> String { + let id = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return id.split(separator: "/").last.map(String.init) ?? id + } + + private static func isOSeries(_ id: String) -> Bool { + guard id.hasPrefix("o"), let second = id.dropFirst().first else { return false } + return second.isNumber + } + + /// Parses "gpt-6-luna" → (6, 0), "gpt-5.1" → (5, 1). + private static func gptVersion(_ id: String) -> (major: Int, minor: Int)? { + guard id.hasPrefix("gpt-") else { return nil } + let rest = id.dropFirst(4) + let major = rest.prefix(while: \.isNumber) + guard let majorValue = Int(major) else { return nil } + var minorValue = 0 + let afterMajor = rest.dropFirst(major.count) + if afterMajor.first == "." { + minorValue = Int(afterMajor.dropFirst().prefix(while: \.isNumber)) ?? 0 } + return (majorValue, minorValue) } } diff --git a/Sources/LLM/ProviderProfile.swift b/Sources/LLM/ProviderProfile.swift index f8ef45f..83cfcb3 100644 --- a/Sources/LLM/ProviderProfile.swift +++ b/Sources/LLM/ProviderProfile.swift @@ -51,7 +51,7 @@ struct ProviderProfile: Codable, Equatable, Identifiable, Sendable { static func openAIPreset() -> ProviderProfile { ProviderProfile(id: "preset.openai", name: "OpenAI", kind: .openAICompatible, baseURL: "https://api.openai.com", chatEndpoint: "/v1/chat/completions", - model: "gpt-4o-mini") + model: "gpt-6-luna") } static func anthropicPreset() -> ProviderProfile { @@ -63,7 +63,7 @@ struct ProviderProfile: Codable, Equatable, Identifiable, Sendable { static func openRouterPreset() -> ProviderProfile { ProviderProfile(id: "preset.openrouter", name: "OpenRouter", kind: .openAICompatible, baseURL: "https://openrouter.ai/api", chatEndpoint: "/v1/chat/completions", - model: "openai/gpt-4o-mini") + model: "openai/gpt-6-luna") } static func groqPreset() -> ProviderProfile { diff --git a/Tests/ProviderProfileTests.swift b/Tests/ProviderProfileTests.swift index 1aebde8..8f88912 100644 --- a/Tests/ProviderProfileTests.swift +++ b/Tests/ProviderProfileTests.swift @@ -20,6 +20,12 @@ final class ProviderProfileTests: XCTestCase { XCTAssertEqual(ids.count, Set(ids).count) } + func testOpenAIPresetsDefaultToGPT6Luna() { + let presets = ProviderProfile.presets() + XCTAssertEqual(presets.first { $0.id == "preset.openai" }?.model, "gpt-6-luna") + XCTAssertEqual(presets.first { $0.id == "preset.openrouter" }?.model, "openai/gpt-6-luna") + } + func testChinaProvidersPresent() { let presets = ProviderProfile.presets() func preset(_ id: String) -> ProviderProfile? { presets.first { $0.id == id } } diff --git a/Tests/ProviderRequestTests.swift b/Tests/ProviderRequestTests.swift index 125f8d4..e9a27e6 100644 --- a/Tests/ProviderRequestTests.swift +++ b/Tests/ProviderRequestTests.swift @@ -89,3 +89,47 @@ final class ProviderURLSplitterTests: XCTestCase { XCTAssertEqual(ProviderURLSplitter.extraHeaders("not json"), [:]) } } + +/// Reasoning models (o-series, gpt-5+, e.g. gpt-6-luna) reject `temperature` +/// with HTTP 400 and take `reasoning_effort` instead. +final class OpenAIModelTraitsTests: XCTestCase { + func testDetectsReasoningModels() { + for model in ["gpt-6-luna", "gpt-6-sol", "gpt-6.1-sol", "gpt-5", "gpt-5-mini", "gpt-5.2", + "o1", "o3-mini", "o4-mini", "openai/gpt-6-luna", "GPT-6-Luna"] { + XCTAssertTrue(OpenAIModelTraits.isReasoningModel(model), model) + } + for model in ["gpt-4o-mini", "gpt-4.1", "gpt-5-chat-latest", "deepseek-v4-flash", + "glm-5.1", "kimi-k2.6", "llama-3.1-8b-instant", "openai/gpt-4o-mini", "omni"] { + XCTAssertFalse(OpenAIModelTraits.isReasoningModel(model), model) + } + } + + func testPicksLowestLatencyReasoningEffort() { + XCTAssertEqual(OpenAIModelTraits.reasoningEffort(for: "gpt-6-luna"), "none") + XCTAssertEqual(OpenAIModelTraits.reasoningEffort(for: "openai/gpt-6-luna"), "none") + XCTAssertEqual(OpenAIModelTraits.reasoningEffort(for: "gpt-5.1"), "none") + XCTAssertEqual(OpenAIModelTraits.reasoningEffort(for: "gpt-5-mini"), "minimal") + XCTAssertEqual(OpenAIModelTraits.reasoningEffort(for: "o4-mini"), "low") + XCTAssertNil(OpenAIModelTraits.reasoningEffort(for: "gpt-4o-mini")) + } + + func testReasoningModelBodyOmitsTemperature() { + let profile = ProviderProfile.openAIPreset() + let req = LLMRequest(model: "gpt-6-luna", systemPrompt: "sys", userPrompt: "usr", temperature: 0, maxTokens: 32) + let body = ProviderRequestFactory.body(profile: profile, request: req) + + XCTAssertNil(body["temperature"]) + XCTAssertNil(body["max_tokens"]) + XCTAssertEqual(body["max_completion_tokens"] as? Int, 32) + XCTAssertEqual(body["reasoning_effort"] as? String, "none") + } + + func testClassicModelBodyKeepsTemperature() { + let profile = ProviderProfile.openAIPreset() + let req = LLMRequest(model: "gpt-4o-mini", systemPrompt: "sys", userPrompt: "usr", temperature: 0.1) + let body = ProviderRequestFactory.body(profile: profile, request: req) + + XCTAssertEqual(body["temperature"] as? Double, 0.1) + XCTAssertNil(body["reasoning_effort"]) + } +}