Skip to content
Open
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
69 changes: 65 additions & 4 deletions Sources/LLM/LLMProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -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)
}
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/LLM/ProviderProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions Tests/ProviderProfileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down
44 changes: 44 additions & 0 deletions Tests/ProviderRequestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}