diff --git a/Strand/AI/AIProvider.swift b/Strand/AI/AIProvider.swift index b37a29f2c5..6b6fb6cf13 100644 --- a/Strand/AI/AIProvider.swift +++ b/Strand/AI/AIProvider.swift @@ -24,7 +24,7 @@ enum AIProvider: String, CaseIterable, Identifiable { switch self { case .openAI: return "gpt-5-mini" case .anthropic: return "claude-sonnet-4-6" - case .gemini: return "gemini-flash-latest" // stable alias → current Flash, no version churn (#400) + case .gemini: return "gemini-flash-latest" // provider-managed alias for the current Flash model case .custom: return "" // the user picks the model their server serves } } @@ -34,16 +34,12 @@ enum AIProvider: String, CaseIterable, Identifiable { var modelOptions: [String] { switch self { case .openAI: - // Pinned ids, not aliases: OpenAI has no stable per-tier "-latest" alias the way Gemini - // does (#400), so this list is bumped by hand. `refreshModels()` merges the live /models - // catalogue, which stays the authority for anything released after this. + // Versioned model families; refreshModels merges newer releases from the live catalogue. // - // The reasoning tiers (o3, o4-mini) and the GPT-5 family reject `temperature` and - // `max_tokens`. Nothing special is needed for them here: the request path sends the - // classic parameters, and on a 400 naming one of them retries with - // `max_completion_tokens` and no temperature (see AiCoach's modernParams leg). The cost - // is one extra round trip on the first message, not a per-model table to maintain. + // Both request paths use max_completion_tokens; classic GPT sampling stays unchanged. return [ + "gpt-5.4", + "gpt-5.4-mini", "gpt-5", "gpt-5-mini", "gpt-5-nano", @@ -57,18 +53,15 @@ enum AIProvider: String, CaseIterable, Identifiable { ] case .anthropic: return [ + "claude-opus-5-5", + "claude-sonnet-5-5", "claude-opus-4-8", "claude-sonnet-4-6", - "claude-haiku-4-5-20251001", - "claude-3-7-sonnet-latest", - "claude-3-5-sonnet-latest", - "claude-3-5-haiku-latest", - "claude-3-opus-latest" + "claude-haiku-4-5-20251001" ] case .gemini: - // Stable `-latest` ALIASES, not pinned versions (#400): they always resolve to the current - // stable model in each tier, so Gemini's rapid releases never need a code bump. `refreshModels()` - // still merges the live `/models` catalogue, so a user with a key can pin a concrete version. + // Provider-managed aliases may point to stable or preview releases. The live model list + // lets the user pin a concrete version instead. return [ "gemini-pro-latest", "gemini-flash-latest", @@ -391,6 +384,10 @@ func performStreamingRequest( // Read line-by-line from the SSE byte stream. `URLSession.AsyncBytes` splits on \n. for try await line in bytes.0.lines { if let payload = SseDeltas.dataPayload(fromLine: line) { + if let json = try? JSONSerialization.jsonObject(with: Data(payload.utf8)) as? [String: Any], + json["error"] is [String: Any] { + throw emptyReplyError(json) + } onLine(payload) } } diff --git a/Strand/AI/Providers/Anthropic.swift b/Strand/AI/Providers/Anthropic.swift index 2b80518b32..5b84a36953 100644 --- a/Strand/AI/Providers/Anthropic.swift +++ b/Strand/AI/Providers/Anthropic.swift @@ -31,15 +31,21 @@ struct AnthropicClient: AIProviderClient { req.httpBody = try JSONSerialization.data(withJSONObject: body) let json = try await performRequest(req, session: session) - guard let content = json["content"] as? [[String: Any]], - let first = content.first, - let text = (first["text"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + let text = Self.replyText(json) + guard !text.isEmpty else { throw emptyReplyError(json) // #1074: surface the provider's real error if the 200 body has one } return text } + /// Thinking and tool blocks may precede or separate the visible text blocks. + static func replyText(_ json: [String: Any]) -> String { + let blocks = json["content"] as? [[String: Any]] ?? [] + return blocks.compactMap { block -> String? in + block["type"] as? String == "text" ? block["text"] as? String : nil + }.joined().trimmingCharacters(in: .whitespacesAndNewlines) + } + /// K1: Stream via `stream: true`. Anthropic SSE uses typed events; we extract `content_block_delta` /// with `text_delta` via `SseDeltas.anthropicDelta`. Byte-parity pin in /// `SseDeltasTests.anthropicReassembleMatchesFullReply`. @@ -77,20 +83,31 @@ struct AnthropicClient: AIProviderClient { } func fetchModels(key: String, session: URLSession) async throws -> [String] { - var req = URLRequest(url: AIProvider.anthropic.modelsEndpoint) - req.httpMethod = "GET" - req.setValue(key, forHTTPHeaderField: "x-api-key") - req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") - - return parseModels(try await performRequest(req, session: session)) + var ids: [String] = [] + var cursor: String? + var seenCursors = Set() + repeat { + var url = URLComponents(url: AIProvider.anthropic.modelsEndpoint, resolvingAgainstBaseURL: false)! + if let cursor { url.queryItems = [URLQueryItem(name: "after_id", value: cursor)] } + var req = URLRequest(url: url.url!) + req.setValue(key, forHTTPHeaderField: "x-api-key") + req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + let json = try await performRequest(req, session: session) + for id in parseModels(json) where !ids.contains(id) { ids.append(id) } + cursor = json["has_more"] as? Bool == true ? json["last_id"] as? String : nil + } while cursor.map { !$0.isEmpty && seenCursors.insert($0).inserted } == true + return ids } /// Pure: unwrap the `/models` body into ids (Anthropic keeps all non-empty). No network — unit-tested. func parseModels(_ json: [String: Any]) -> [String] { guard let list = json["data"] as? [[String: Any]] else { return [] } - return list.compactMap { row in - guard let id = row["id"] as? String, !id.isEmpty else { return nil } - return id + var ids: [String] = [] + for row in list { + guard let raw = row["id"] as? String else { continue } + let id = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if !id.isEmpty, !ids.contains(id) { ids.append(id) } } + return ids } } diff --git a/Strand/AI/Providers/Custom.swift b/Strand/AI/Providers/Custom.swift index a52b33c229..4ae5566f8b 100644 --- a/Strand/AI/Providers/Custom.swift +++ b/Strand/AI/Providers/Custom.swift @@ -23,9 +23,7 @@ struct CustomClient: AIProviderClient { do { return try await chat(key: key, model: model, wire: wire, modernParams: false, session: session) } catch let AICoachError.server(code, detail) where code == 400 { - let d = detail.lowercased() - if d.contains("max_completion_tokens") || d.contains("max_tokens") - || d.contains("temperature") || d.contains("unsupported") { + if Self.needsModernParams(detail) { return try await chat(key: key, model: model, wire: wire, modernParams: true, session: session) } throw AICoachError.server(code, detail) @@ -34,7 +32,7 @@ struct CustomClient: AIProviderClient { /// K1: Stream via `stream: true` (most local OpenAI-compatible servers support it). Same body /// as `send`'s standard-params path, with `stream: true`. SSE parsing via `SseDeltas.openAiDelta`. - /// The modern-params retry on 400 is NOT streamed (rare path; falls back to `send`'s retry). + /// A parameter rejection retries once with modern parameters and emits the full reply. func stream( key: String, model: String, @@ -61,13 +59,22 @@ 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 && Self.needsModernParams(detail) { + onDelta(try await chat(key: key, model: model, wire: wire, modernParams: true, session: session)) } } + private static func needsModernParams(_ detail: String) -> Bool { + let d = detail.lowercased() + return d.contains("max_completion_tokens") || d.contains("max_tokens") || d.contains("temperature") + } + /// Appended to a reply when the server stopped early because it ran out of context window. /// Local OpenAI-compatible servers (notably Ollama, which defaults to a 2048-token window and /// IGNORES `num_ctx` on the `/v1` endpoint) truncate silently — no error, the text just stops diff --git a/Strand/AI/Providers/Gemini.swift b/Strand/AI/Providers/Gemini.swift index ccf4829b22..1e0aa8d04d 100644 --- a/Strand/AI/Providers/Gemini.swift +++ b/Strand/AI/Providers/Gemini.swift @@ -1,6 +1,15 @@ import Foundation import StrandAnalytics +/// Native Gemini body, preserving the existing sampling value and output cap. +func geminiRequestBody(systemPrompt: String, contents: [[String: Any]]) -> [String: Any] { + [ + "system_instruction": ["parts": [["text": systemPrompt]]], + "contents": contents, + "generationConfig": ["temperature": 0.6, "maxOutputTokens": 4096] as [String: Any] + ] +} + struct GeminiClient: AIProviderClient { func send( @@ -11,15 +20,7 @@ struct GeminiClient: AIProviderClient { session: URLSession ) async throws -> String { let contents = GeminiClient.buildContents(from: messages) - let body: [String: Any] = [ - "system_instruction": ["parts": [["text": systemPrompt]]], - "contents": contents, - // Gemini 2.5 counts THINKING tokens against maxOutputTokens; the other providers' - // visible-reply cap (900) starves thinking models into empty replies (finishReason - // MAX_TOKENS, no text parts). 4096 leaves room for both — the system prompt keeps the - // visible reply short. - "generationConfig": ["temperature": 0.6, "maxOutputTokens": 4096] - ] + let body = geminiRequestBody(systemPrompt: systemPrompt, contents: contents) // Built via URL(string:): appendingPathComponent percent-encodes the ":" in // ":generateContent" on some Foundation versions and the API rejects %3A. @@ -33,12 +34,11 @@ struct GeminiClient: AIProviderClient { req.httpBody = try JSONSerialization.data(withJSONObject: body) let json = try await performRequest(req, session: session) - // Reply text can span several parts; join them (thinking models may emit more than one). guard let candidates = json["candidates"] as? [[String: Any]], let first = candidates.first, let content = first["content"] as? [String: Any], let parts = content["parts"] as? [[String: Any]] else { - throw emptyReplyError(json) // #1074: surface the provider's real error if the 200 body has one + throw emptyReplyError(json) } let text = parts.compactMap { $0["text"] as? String }.joined() .trimmingCharacters(in: .whitespacesAndNewlines) @@ -79,30 +79,8 @@ struct GeminiClient: AIProviderClient { session: URLSession, onDelta: (String) -> Void ) async throws { - let contents = GeminiClient.buildContents(from: messages) - - let body: [String: Any] = [ - "system_instruction": ["parts": [["text": systemPrompt]]], - "contents": contents, - "generationConfig": ["temperature": 0.6, "maxOutputTokens": 4096] - ] - - // :streamGenerateContent?alt=sse — SSE line-per-chunk. Built via URL(string:) to avoid - // appendingPathComponent percent-encoding the ":" (same note as `send`). - guard let url = URL(string: "\(AIProvider.gemini.endpoint.absoluteString)/\(model):streamGenerateContent?alt=sse") else { - throw AICoachError.network("invalid model id") - } - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.setValue(key, forHTTPHeaderField: "x-goog-api-key") - 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.geminiDelta(payload) { - onDelta(delta) - } - } + try await streamWithImage(key: key, model: model, systemPrompt: systemPrompt, + messages: messages, inlineImage: nil, session: session, onDelta: onDelta) } /// K11: Stream with an optional inline image (base64 PNG). Appends the image as @@ -121,11 +99,7 @@ struct GeminiClient: AIProviderClient { GeminiClient.appendImage(image, mimeType: "image/png", to: &contents) } - let body: [String: Any] = [ - "system_instruction": ["parts": [["text": systemPrompt]]], - "contents": contents, - "generationConfig": ["temperature": 0.6, "maxOutputTokens": 4096] - ] + let body = geminiRequestBody(systemPrompt: systemPrompt, contents: contents) guard let url = URL(string: "\(AIProvider.gemini.endpoint.absoluteString)/\(model):streamGenerateContent?alt=sse") else { throw AICoachError.network("invalid model id") @@ -143,23 +117,37 @@ struct GeminiClient: AIProviderClient { } } - func fetchModels(key: String, session: URLSession) async throws -> [String] { var req = URLRequest(url: AIProvider.gemini.modelsEndpoint) - req.httpMethod = "GET" - req.setValue(key, forHTTPHeaderField: "x-goog-api-key") - - return parseModels(try await performRequest(req, session: session)) + func fetchModels(key: String, session: URLSession) async throws -> [String] { + var ids: [String] = [] + var cursor: String? + var seenCursors = Set() + repeat { + var url = URLComponents(url: AIProvider.gemini.modelsEndpoint, resolvingAgainstBaseURL: false)! + if let cursor { url.queryItems = [URLQueryItem(name: "pageToken", value: cursor)] } + var req = URLRequest(url: url.url!) + req.setValue(key, forHTTPHeaderField: "x-goog-api-key") + let json = try await performRequest(req, session: session) + for id in parseModels(json) where !ids.contains(id) { ids.append(id) } + cursor = json["nextPageToken"] as? String + } while cursor.map { !$0.isEmpty && seenCursors.insert($0).inserted } == true + return ids } /// Pure: unwrap Gemini's `{"models":[{"name":"models/…"}]}` — strip the prefix, keep chat-capable - /// gemini-* only (drop embeddings/AQA). No network — unit-tested. + /// text generation models only, using supportedGenerationMethods when supplied. func parseModels(_ json: [String: Any]) -> [String] { guard let list = json["models"] as? [[String: Any]] else { return [] } - return list.compactMap { row in - guard let name = row["name"] as? String, !name.isEmpty else { return nil } + var ids: [String] = [] + for row in list { + guard let raw = row["name"] as? String else { continue } + let name = raw.trimmingCharacters(in: .whitespacesAndNewlines) let id = name.hasPrefix("models/") ? String(name.dropFirst("models/".count)) : name guard id.hasPrefix("gemini"), - !id.contains("embedding"), !id.contains("aqa") else { return nil } - return id + !["embedding", "aqa", "-tts", "-live", "native-audio"].contains(where: id.contains) else { continue } + if let methods = row["supportedGenerationMethods"] as? [String], + !methods.contains("generateContent") { continue } + if !ids.contains(id) { ids.append(id) } } + return ids } } diff --git a/Strand/AI/Providers/OpenAI.swift b/Strand/AI/Providers/OpenAI.swift index 78f06f5658..dee162a20d 100644 --- a/Strand/AI/Providers/OpenAI.swift +++ b/Strand/AI/Providers/OpenAI.swift @@ -1,6 +1,23 @@ import Foundation import StrandAnalytics +/// Shared Chat Completions body for regular and streaming requests, including reasoning models. +func openAIChatBody(model: String, messages: [[String: Any]], stream: Bool) -> [String: Any] { + var body: [String: Any] = ["model": model, "messages": messages, "max_completion_tokens": 4096] + if (model.hasPrefix("gpt-4") || model.hasPrefix("gpt-3.5") || model.hasPrefix("gpt-audio")), + !model.contains("-search") { body["temperature"] = 0.6 } + if stream { body["stream"] = true } + return body +} + +/// Exclude models whose endpoints or output formats this text-chat client cannot handle. +func isOpenAIChatModel(_ id: String) -> Bool { + let reasoning = id.hasPrefix("o") && id.dropFirst().first.map { ("0"..."9").contains($0) } == true + let gpt = id.hasPrefix("gpt") + let specialized = ["gpt-image", "-pro", "-codex", "-deep-research", "-instruct", "-realtime", "-transcribe", "-tts"] + return (gpt || reasoning) && !specialized.contains(where: id.contains) +} + struct OpenAIClient: AIProviderClient { func send( @@ -13,23 +30,19 @@ struct OpenAIClient: AIProviderClient { var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]] for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) } - // Standard params first (gpt-4 family). Newer/reasoning models reject `temperature` and want - // `max_completion_tokens`; if the provider 400s about either, retry with the modern shape. - do { - return try await chat(key: key, model: model, wire: wire, modernParams: false, session: session) - } catch let AICoachError.server(code, detail) where code == 400 { - let d = detail.lowercased() - if d.contains("max_completion_tokens") || d.contains("max_tokens") - || d.contains("temperature") || d.contains("unsupported") { - return try await chat(key: key, model: model, wire: wire, modernParams: true, session: session) - } - throw AICoachError.server(code, detail) + let req = try chatRequest(key: key, model: model, wire: wire, stream: false) + let json = try await performRequest(req, session: session) + guard let choices = json["choices"] as? [[String: Any]], + let first = choices.first, + let message = first["message"] as? [String: Any], + let content = (message["content"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty else { + throw emptyReplyError(json) } + return content } - /// K1: Stream via `stream: true`. Same body as `send`, with `stream: true` added. SSE parsing - /// via `SseDeltas.openAiDelta`. The modern-params retry on 400 is NOT streamed (rare path; - /// falls back to `send`'s retry). Byte-parity pin in `SseDeltasTests.openAiReassembleMatchesFullReply`. + /// Stream the same parameters as `send`, with `stream: true` added. func stream( key: String, model: String, @@ -41,15 +54,7 @@ struct OpenAIClient: AIProviderClient { var wire: [[String: Any]] = [["role": "system", "content": systemPrompt]] for m in messages { wire.append(["role": m.role.rawValue, "content": m.content]) } - var body: [String: Any] = ["model": model, "messages": wire, "stream": true] - body["temperature"] = 0.6 - body["max_tokens"] = 4096 - - var req = URLRequest(url: AIProvider.openAI.endpoint) - req.httpMethod = "POST" - req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.httpBody = try JSONSerialization.data(withJSONObject: body) + let req = try chatRequest(key: key, model: model, wire: wire, stream: true) try await performStreamingRequest(req, session: session) { payload in if let delta = SseDeltas.openAiDelta(payload) { @@ -66,49 +71,31 @@ struct OpenAIClient: AIProviderClient { return parseModels(try await performRequest(req, session: session)) } - /// Pure: unwrap the `/models` body into chat-capable ids (gpt*/o*). No network — unit-tested. + /// Unwrap the `/models` body into candidates for this client's Chat Completions endpoint. func parseModels(_ json: [String: Any]) -> [String] { guard let list = json["data"] as? [[String: Any]] else { return [] } - return list.compactMap { row in - guard let id = row["id"] as? String, !id.isEmpty else { return nil } - return (id.hasPrefix("gpt") || id.hasPrefix("o")) ? id : nil + var ids: [String] = [] + for row in list { + guard let raw = row["id"] as? String else { continue } + let id = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if isOpenAIChatModel(id), !ids.contains(id) { ids.append(id) } } + return ids } // MARK: Private - /// `modernParams`: use `max_completion_tokens`, drop `temperature` — required by reasoning models. - private func chat( + private func chatRequest( key: String, model: String, wire: [[String: Any]], - modernParams: Bool, - session: URLSession - ) async throws -> String { - var body: [String: Any] = ["model": model, "messages": wire] - // #1074: 900 truncated detailed coaching replies mid-sentence; 4096 lets a full multi-section - // reply complete (a cap, not a target — the system prompt keeps it short). Matches Gemini + Android. - if modernParams { - body["max_completion_tokens"] = 4096 - } else { - body["temperature"] = 0.6 - body["max_tokens"] = 4096 - } - + stream: Bool + ) throws -> URLRequest { var req = URLRequest(url: AIProvider.openAI.endpoint) req.httpMethod = "POST" req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.httpBody = try JSONSerialization.data(withJSONObject: body) - - let json = try await performRequest(req, session: session) - guard let choices = json["choices"] as? [[String: Any]], - let first = choices.first, - let message = first["message"] as? [String: Any], - let content = (message["content"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty else { - throw emptyReplyError(json) // #1074: surface the provider's real error if the 200 body has one - } - return content + req.httpBody = try JSONSerialization.data(withJSONObject: openAIChatBody(model: model, messages: wire, stream: stream)) + return req } } diff --git a/StrandTests/AICoachRateLimitTests.swift b/StrandTests/AICoachRateLimitTests.swift index 6ec3d20c6a..807e319d5e 100644 --- a/StrandTests/AICoachRateLimitTests.swift +++ b/StrandTests/AICoachRateLimitTests.swift @@ -5,12 +5,17 @@ import XCTest private final class AICoachURLProtocolStub: URLProtocol { struct Stubbed { let status: Int; let body: Data } nonisolated(unsafe) static var response = Stubbed(status: 500, body: Data()) + nonisolated(unsafe) static var queue: [Stubbed] = [] + nonisolated(unsafe) static var requestCount = 0 + nonisolated(unsafe) static var requestURLs: [URL] = [] override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { - let stub = Self.response + Self.requestCount += 1 + Self.requestURLs.append(request.url!) + let stub = Self.queue.isEmpty ? Self.response : Self.queue.removeFirst() let response = HTTPURLResponse( url: request.url!, statusCode: stub.status, httpVersion: nil, headerFields: nil )! @@ -23,6 +28,9 @@ private final class AICoachURLProtocolStub: URLProtocol { static func session(status: Int, body: String) -> URLSession { response = Stubbed(status: status, body: Data(body.utf8)) + queue = [] + requestCount = 0 + requestURLs = [] let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [AICoachURLProtocolStub.self] return URLSession(configuration: configuration) @@ -71,4 +79,128 @@ final class AICoachRateLimitTests: XCTestCase { func testMissingProviderMessageKeepsExistingCopy() { XCTAssertEqual(AICoachError.rateLimited("").errorDescription, base) } + + func testStreamingErrorInsideSuccessfulHttpResponseIsSurfaced() async { + let session = AICoachURLProtocolStub.session( + status: 200, + body: "data: {\"choices\":[{\"delta\":{\"content\":\"Hallo\"}}],\"error\":null}\n\n" + + "data: {\"error\":{\"message\":\"Generation failed\"}}\n\n" + ) + defer { session.invalidateAndCancel() } + var partial = "" + do { + try await OpenAIClient().stream( + key: "test-key", model: "gpt-5.4", systemPrompt: "Coach", + messages: [(.user, "Hallo")], session: session + ) { partial += $0 } + XCTFail("expected the provider's streaming error") + } catch let error as AICoachError { + XCTAssertEqual(error.errorDescription, "The provider returned an error: Generation failed") + XCTAssertEqual(partial, "Hallo") + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testCustomParameterRetryWorksForRegularAndStreamingReplies() async throws { + let previousURL = UserDefaults.standard.string(forKey: AIProvider.customBaseURLKey) + UserDefaults.standard.set("https://provider.invalid/v1", forKey: AIProvider.customBaseURLKey) + defer { + if let previousURL { + UserDefaults.standard.set(previousURL, forKey: AIProvider.customBaseURLKey) + } else { + UserDefaults.standard.removeObject(forKey: AIProvider.customBaseURLKey) + } + } + for stream in [false, true] { + let session = AICoachURLProtocolStub.session(status: 500, body: "{}") + defer { session.invalidateAndCancel() } + AICoachURLProtocolStub.queue = [ + .init(status: 400, body: Data(#"{"error":{"message":"Use max_completion_tokens instead of max_tokens"}}"#.utf8)), + .init(status: 200, body: Data(#"{"choices":[{"message":{"content":"Hallo"}}]}"#.utf8)) + ] + var reply = "" + if stream { + try await CustomClient().stream( + key: "test-key", model: "gpt-5.4", systemPrompt: "Coach", + messages: [(.user, "Hallo")], session: session + ) { reply += $0 } + } else { + reply = try await CustomClient().send( + key: "test-key", model: "gpt-5.4", systemPrompt: "Coach", + messages: [(.user, "Hallo")], session: session + ) + } + XCTAssertEqual(reply, "Hallo") + XCTAssertEqual(AICoachURLProtocolStub.requestCount, 2) + } + } + + func testAnthropicReadsTextAfterThinkingAndAcrossBlocks() async throws { + let session = AICoachURLProtocolStub.session( + status: 200, + body: #"{"content":[{"type":"thinking","thinking":"Summary"},{"type":"text","text":"Hallo "},{"type":"tool_use","name":"ignored"},{"type":"text","text":"Welt"}]}"# + ) + defer { session.invalidateAndCancel() } + let reply = try await AnthropicClient().send( + key: "test-key", model: "claude-sonnet-5-5", systemPrompt: "Coach", + messages: [(.user, "Hallo")], session: session + ) + XCTAssertEqual(reply, "Hallo Welt") + } + + func testGeminiRegularAndStreamingPreserveAllTextParts() async throws { + let json = #"{"candidates":[{"content":{"parts":[{"thought":true,"text":"Reasoning summary"},{"text":"Hallo "},{"thought":false,"text":"Welt"}]}}]}"# + for stream in [false, true] { + let session = AICoachURLProtocolStub.session(status: 200, body: stream ? "data: \(json)\n\n" : json) + defer { session.invalidateAndCancel() } + var reply = "" + if stream { + try await GeminiClient().stream( + key: "test-key", model: "gemini-flash-latest", systemPrompt: "Coach", + messages: [(.user, "Hallo")], session: session + ) { reply += $0 } + } else { + reply = try await GeminiClient().send( + key: "test-key", model: "gemini-flash-latest", systemPrompt: "Coach", + messages: [(.user, "Hallo")], session: session + ) + } + XCTAssertEqual(reply, "Reasoning summaryHallo Welt") + } + } + + func testModelFetchReadsFollowingPagesAndDeduplicates() async throws { + let cases: [(AIProvider, [String], String, [String])] = [ + (.anthropic, [ + #"{"data":[{"id":"claude-sonnet-4-6"}],"has_more":true,"last_id":"claude-sonnet-4-6"}"#, + #"{"data":[{"id":"claude-sonnet-4-6"},{"id":"claude-sonnet-5-5"}],"has_more":false}"# + ], "after_id", ["claude-sonnet-4-6", "claude-sonnet-5-5"]), + (.gemini, [ + #"{"models":[{"name":"models/gemini-3-pro"}],"nextPageToken":"next +/="}"#, + #"{"models":[{"name":"models/gemini-3-pro"},{"name":"models/gemini-3-flash"}]}"# + ], "pageToken", ["gemini-3-pro", "gemini-3-flash"]) + ] + for (provider, pages, parameter, expected) in cases { + let session = AICoachURLProtocolStub.session(status: 500, body: "{}") + defer { session.invalidateAndCancel() } + AICoachURLProtocolStub.queue = pages.map { .init(status: 200, body: Data($0.utf8)) } + let ids = try await provider.client.fetchModels(key: "test-key", session: session) + XCTAssertEqual(ids, expected) + XCTAssertEqual(AICoachURLProtocolStub.requestCount, 2) + let url = try XCTUnwrap(AICoachURLProtocolStub.requestURLs.last) + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(items?.first?.name, parameter) + XCTAssertEqual(items?.first?.value, provider == .gemini ? "next +/=" : "claude-sonnet-4-6") + } + } + + func testModelFetchStopsOnRepeatedCursor() async throws { + let page = #"{"models":[{"name":"models/gemini-3-pro"}],"nextPageToken":"same"}"# + let session = AICoachURLProtocolStub.session(status: 200, body: page) + defer { session.invalidateAndCancel() } + let ids = try await GeminiClient().fetchModels(key: "test-key", session: session) + XCTAssertEqual(ids, ["gemini-3-pro"]) + XCTAssertEqual(AICoachURLProtocolStub.requestCount, 2) + } } diff --git a/StrandTests/AIProviderModelListTests.swift b/StrandTests/AIProviderModelListTests.swift index b5e627a84f..5df86389bb 100644 --- a/StrandTests/AIProviderModelListTests.swift +++ b/StrandTests/AIProviderModelListTests.swift @@ -49,6 +49,12 @@ final class AIProviderModelListTests: XCTestCase { XCTAssertTrue(GeminiClient().parseModels(body).isEmpty) } + func testGeminiRequiresTextGenerationAndExcludesSpecializedModels() throws { + let fixture = #"{"models":[{"name":"models/gemini-3-pro","supportedGenerationMethods":["generateContent","countTokens"]},{"name":"models/gemini-live","supportedGenerationMethods":["bidiGenerateContent"]},{"name":"models/gemini-3-flash-preview-tts","supportedGenerationMethods":["generateContent"]},{"name":"models/gemini-2.5-flash-native-audio","supportedGenerationMethods":["bidiGenerateContent"]},{"name":"models/gemini-no-generation","supportedGenerationMethods":[]},{"name":" models/gemini-3-pro "}]}"# + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(fixture.utf8)) as? [String: Any]) + XCTAssertEqual(GeminiClient().parseModels(json), ["gemini-3-pro"]) + } + // MARK: - existing providers unchanged func testOpenAIFiltersToGptAndOFamilies() { @@ -56,11 +62,21 @@ final class AIProviderModelListTests: XCTestCase { "data": [ ["id": "gpt-4o"], ["id": "o3-mini"], + ["id": "gpt-5.4"], + ["id": "gpt-5.4-pro"], // Responses only — drop + ["id": "gpt-5.4-pro-2026-03-05"], // Responses snapshot — drop + ["id": "gpt-image-1"], + ["id": "gpt-5.3-codex"], + ["id": "o3-deep-research"], + ["id": "gpt-4o-realtime-preview"], + ["id": "gpt-4o-mini-transcribe"], + ["id": "gpt-3.5-turbo-instruct"], + ["id": "omni-moderation-latest"], // moderation — drop ["id": "text-embedding-3-large"], // not gpt/o — drop ["id": ""] // empty — drop ] ] - XCTAssertEqual(OpenAIClient().parseModels(body), ["gpt-4o", "o3-mini"]) + XCTAssertEqual(OpenAIClient().parseModels(body), ["gpt-4o", "o3-mini", "gpt-5.4"]) } func testAnthropicKeepsAllNonEmptyIds() { @@ -74,6 +90,12 @@ final class AIProviderModelListTests: XCTestCase { XCTAssertEqual(AnthropicClient().parseModels(body), ["claude-sonnet-4-6", "claude-opus-4-8"]) } + func testAnthropicPickerOffersActiveModelsInsteadOfRetiredClaude3() { + XCTAssertTrue(AIProvider.anthropic.modelOptions.contains("claude-sonnet-5-5")) + XCTAssertTrue(AIProvider.anthropic.modelOptions.contains("claude-opus-5-5")) + XCTAssertFalse(AIProvider.anthropic.modelOptions.contains { $0.hasPrefix("claude-3") }) + } + // MARK: - Custom (OpenAI-compatible / local LLM) func testCustomKeepsAllNonEmptyIdsUnfiltered() { diff --git a/StrandTests/OpenAIRequestTests.swift b/StrandTests/OpenAIRequestTests.swift new file mode 100644 index 0000000000..6359423675 --- /dev/null +++ b/StrandTests/OpenAIRequestTests.swift @@ -0,0 +1,66 @@ +import Foundation +import XCTest +@testable import Strand + +final class OpenAIRequestTests: XCTestCase { + func testRegularAndStreamingBodiesUseReasoningCompatibleParameters() throws { + let messages: [[String: Any]] = [["role": "user", "content": "Hallo"]] + for model in ["gpt-5.4", "gpt-5-mini", "gpt-4.1", "o3"] { + for stream in [false, true] { + let body = openAIChatBody(model: model, messages: messages, stream: stream) + XCTAssertEqual(body["model"] as? String, model) + XCTAssertEqual(body["max_completion_tokens"] as? Int, 4096) + XCTAssertNil(body["max_tokens"]) + XCTAssertEqual(body["temperature"] as? Double, model == "gpt-4.1" ? 0.6 : nil) + XCTAssertEqual(body["stream"] as? Bool, stream ? true : nil) + let wire = try JSONSerialization.data(withJSONObject: body) + let decoded = try XCTUnwrap(JSONSerialization.jsonObject(with: wire) as? [String: Any]) + XCTAssertEqual((decoded["messages"] as? [[String: String]])?.first?["content"], "Hallo") + } + } + } + + func testGPT54IsAvailableWithoutRefreshingModels() { + XCTAssertTrue(AIProvider.openAI.modelOptions.contains("gpt-5.4")) + XCTAssertTrue(AIProvider.openAI.modelOptions.contains("gpt-5.4-mini")) + XCTAssertEqual(AIProvider.openAI.defaultModel, "gpt-5-mini") + let original = ["gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", "o3", "o4-mini"] + XCTAssertTrue(original.allSatisfy(AIProvider.openAI.modelOptions.contains)) + } + + func testGeminiBodyPreservesExistingTemperatureAndOutputCap() throws { + let body = geminiRequestBody(systemPrompt: "Coach", contents: [["role": "user", "parts": [["text": "Hallo"]]]]) + let config = try XCTUnwrap(body["generationConfig"] as? [String: Any]) + XCTAssertEqual(config["maxOutputTokens"] as? Int, 4096) + XCTAssertEqual(config["temperature"] as? Double, 0.6) + } + + func testModelFilterMatchesAndroidOracleCases() { + let expected = """ + gpt-5.4|true + gpt-4o|true + o3-mini|true + gpt-5.4-pro|false + gpt-5.4-pro-2026-03-05|false + omni-moderation-latest|false + o|false + o4|false + text-embedding-3-large|false + gpt-image-1|false + gpt-5.3-codex|false + o3-deep-research|false + gpt-4o-realtime-preview|false + gpt-4o-mini-transcribe|false + gpt-3.5-turbo-instruct|false + gpt-4o-mini-search-preview|true + gpt-4o-mini-tts|false + gpt-4o-audio-preview|true + gpt-audio|true + """ + let actual: String = expected.split(separator: "\n").map { line -> String in + let model = String(line.split(separator: "|")[0]) + return "\(model)|\(isOpenAIChatModel(model))" + }.joined(separator: "\n") + XCTAssertEqual(actual, expected) + } +}