From b78844373f0457ec1d74083752cbe0ff7a8b3901 Mon Sep 17 00:00:00 2001 From: Mohamed El-Samman Date: Mon, 12 Oct 2026 08:32:53 +0300 Subject: [PATCH 1/2] fix(ai): retry streaming OpenAI/Custom requests with max_completion_tokens on 400 The streaming path always sent max_tokens + temperature and had no fallback, so GPT-5 / reasoning models failed with an 'unsupported parameter: max_tokens' error. The non-streaming send() already retries with max_completion_tokens; the streaming comment claimed to fall back to it but never did. On a 400 naming max_tokens/max_completion_tokens/temperature/unsupported (raised before any delta is emitted), fall back to send() and emit the reply as a single delta. Applied to OpenAIClient and CustomClient. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015crzUUNTK64Y8i8QgJDkaz --- Strand/AI/Providers/Custom.swift | 20 +++++++++++++++++--- Strand/AI/Providers/OpenAI.swift | 20 +++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Strand/AI/Providers/Custom.swift b/Strand/AI/Providers/Custom.swift index a52b33c229..3b07bc49fe 100644 --- a/Strand/AI/Providers/Custom.swift +++ b/Strand/AI/Providers/Custom.swift @@ -61,10 +61,24 @@ struct CustomClient: AIProviderClient { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: body) - try await performStreamingRequest(req, session: session) { payload in - if let delta = SseDeltas.openAiDelta(payload) { - onDelta(delta) + do { + try await performStreamingRequest(req, session: session) { payload in + if let delta = SseDeltas.openAiDelta(payload) { + onDelta(delta) + } + } + } catch let AICoachError.server(code, detail) where code == 400 { + // GPT-5 / reasoning models reject `max_tokens` + `temperature`. The 400 arrives before + // any delta, so fall back to `send` (which retries with `max_completion_tokens`) and + // emit the full reply as one delta. + let d = detail.lowercased() + guard d.contains("max_completion_tokens") || d.contains("max_tokens") + || d.contains("temperature") || d.contains("unsupported") else { + throw AICoachError.server(code, detail) } + let reply = try await send(key: key, model: model, systemPrompt: systemPrompt, + messages: messages, session: session) + onDelta(reply) } } diff --git a/Strand/AI/Providers/OpenAI.swift b/Strand/AI/Providers/OpenAI.swift index 78f06f5658..386d03b3a5 100644 --- a/Strand/AI/Providers/OpenAI.swift +++ b/Strand/AI/Providers/OpenAI.swift @@ -51,10 +51,24 @@ struct OpenAIClient: AIProviderClient { req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: body) - try await performStreamingRequest(req, session: session) { payload in - if let delta = SseDeltas.openAiDelta(payload) { - onDelta(delta) + do { + try await performStreamingRequest(req, session: session) { payload in + if let delta = SseDeltas.openAiDelta(payload) { + onDelta(delta) + } + } + } catch let AICoachError.server(code, detail) where code == 400 { + // GPT-5 / reasoning models reject `max_tokens` + `temperature`. The 400 arrives before + // any delta, so fall back to `send` (which retries with `max_completion_tokens`) and + // emit the full reply as one delta. + let d = detail.lowercased() + guard d.contains("max_completion_tokens") || d.contains("max_tokens") + || d.contains("temperature") || d.contains("unsupported") else { + throw AICoachError.server(code, detail) } + let reply = try await send(key: key, model: model, systemPrompt: systemPrompt, + messages: messages, session: session) + onDelta(reply) } } From a83e02b1a495e6f679d339a567e9d98202b21936 Mon Sep 17 00:00:00 2001 From: Mohamed El-Samman Date: Mon, 12 Oct 2026 08:37:50 +0300 Subject: [PATCH 2/2] fix(ai): raise non-streaming chat timeout to 300s for reasoning models GPT-5 can take over 60s before sending the first byte, which tripped the default URLSession idle timeout and surfaced as a network error on the non-streaming fallback. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015crzUUNTK64Y8i8QgJDkaz --- Strand/AI/Providers/Custom.swift | 1 + Strand/AI/Providers/OpenAI.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Strand/AI/Providers/Custom.swift b/Strand/AI/Providers/Custom.swift index 3b07bc49fe..c9131c7289 100644 --- a/Strand/AI/Providers/Custom.swift +++ b/Strand/AI/Providers/Custom.swift @@ -164,6 +164,7 @@ struct CustomClient: AIProviderClient { var req = URLRequest(url: AIProvider.custom.endpoint) req.httpMethod = "POST" + req.timeoutInterval = 300 // reasoning models (GPT-5) can sit silent >60s before the first byte AIProvider.applyCustomAuthHeader(key, to: &req) req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: body) diff --git a/Strand/AI/Providers/OpenAI.swift b/Strand/AI/Providers/OpenAI.swift index 386d03b3a5..45bf29937d 100644 --- a/Strand/AI/Providers/OpenAI.swift +++ b/Strand/AI/Providers/OpenAI.swift @@ -111,6 +111,7 @@ struct OpenAIClient: AIProviderClient { var req = URLRequest(url: AIProvider.openAI.endpoint) req.httpMethod = "POST" + req.timeoutInterval = 300 // reasoning models (GPT-5) can sit silent >60s before the first byte req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: body)