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
31 changes: 14 additions & 17 deletions Strand/AI/AIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)
}
}
Expand Down
43 changes: 30 additions & 13 deletions Strand/AI/Providers/Anthropic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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<String>()
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
}
}
21 changes: 14 additions & 7 deletions Strand/AI/Providers/Custom.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand Down
88 changes: 38 additions & 50 deletions Strand/AI/Providers/Gemini.swift
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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<String>()
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
}
}
Loading
Loading