Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ All notable changes to LLMProviderKit will be documented in this file.

### Fixed
- Decode streaming response lines from complete UTF-8 byte buffers instead of byte-by-byte Unicode scalars, preserving multi-byte characters such as Arabic, CJK, accents, and emoji.
- Send a valid `anthropic-version` header (`2023-06-01`) instead of the branding string, which Anthropic rejected with HTTP 400; moved branding to `User-Agent`.
- Send Gemini tool results (`functionResponse` parts) in a `user` turn instead of `model`; Gemini only accepts `user`/`model` roles and mishandled tool results sent as `model`.

### Added
- Latest curated models: OpenAI GPT-5.6 (`gpt-5.6`), Gemini 3.6 Flash (`gemini-3.6-flash`) and Gemini 3.5 Flash-Lite (`gemini-3.5-flash-lite`), and Anthropic Opus 4.7 / Opus 4.6. Gemini's default preset model is now Gemini 3.6 Flash.
- Regression coverage for non-ASCII streaming text.
- Regression coverage for the Anthropic version header and Gemini tool-result role.
- MIT license file.

## 0.1.0-alpha.4 - 2026-07-06
Expand Down
25 changes: 24 additions & 1 deletion Sources/LLMProviderKitAnthropic/AnthropicProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ public struct AnthropicProvider: LLMProvider {
if let apiKey = configuration.apiKey {
urlRequest.setValue(apiKey, forHTTPHeaderField: "x-api-key")
}
urlRequest.setValue("LLMProviderKit/1.0", forHTTPHeaderField: "anthropic-version")
// Anthropic requires a dated API version string; an arbitrary value is rejected
// with HTTP 400 "invalid anthropic-version".
urlRequest.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
urlRequest.setValue("LLMProviderKit/1.0", forHTTPHeaderField: "User-Agent")

let maxTokens = request.maxTokens ?? 4096

Expand Down Expand Up @@ -405,6 +408,8 @@ public enum AnthropicModel {
// Current models
public static let fable5 = "claude-fable-5"
public static let opus48 = "claude-opus-4-8"
public static let opus47 = "claude-opus-4-7"
public static let opus46 = "claude-opus-4-6"
public static let sonnet46 = "claude-sonnet-4-6"
public static let haiku45 = "claude-haiku-4-5-20251001"
// Legacy
Expand Down Expand Up @@ -440,6 +445,24 @@ extension AnthropicProvider {
categories: [.text, .vision, .multimodal],
releaseStage: .stable
),
LLMModelInfo(
id: AnthropicModel.opus47,
providerName: name,
displayName: "Claude Opus 4.7",
contextWindow: 1_000_000,
capabilities: [.chat, .textGeneration, .streaming, .reasoning, .tools, .vision, .imageInput, .structuredOutput],
categories: [.text, .vision, .multimodal],
releaseStage: .stable
),
LLMModelInfo(
id: AnthropicModel.opus46,
providerName: name,
displayName: "Claude Opus 4.6",
contextWindow: 1_000_000,
capabilities: [.chat, .textGeneration, .streaming, .reasoning, .tools, .vision, .imageInput, .structuredOutput],
categories: [.text, .vision, .multimodal],
releaseStage: .stable
),
LLMModelInfo(
id: AnthropicModel.sonnet46,
providerName: name,
Expand Down
29 changes: 26 additions & 3 deletions Sources/LLMProviderKitGemini/GeminiProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,10 @@ public struct GeminiProvider: LLMProvider {
case .system: return "user"
case .user: return "user"
case .assistant: return "model"
case .tool: return "model" // Gemini uses "model" role for function responses
// Gemini only accepts "user" and "model" roles in `contents`. Function
// responses (`functionResponse` parts) must be sent in a "user" turn;
// sending them as "model" is rejected/mishandled by the API.
case .tool: return "user"
}
}

Expand Down Expand Up @@ -374,7 +377,7 @@ public struct GeminiProvider: LLMProvider {
// Keep this conservative for live metadata. Curated records can add
// reasoning for known flagship non-Pro IDs, but a broad `contains("3.")`
// marks every Gemini 3.x Flash/Lite model as reasoning-capable.
modelID.contains("-pro") || modelID.hasPrefix("gemini-3.5-")
modelID.contains("-pro") || modelID.hasPrefix("gemini-3.5-") || modelID.hasPrefix("gemini-3.6-")
}

private static func categories(for model: GeminiModelsResponse.Model, cleanId: String) -> Set<LLMModelCategory> {
Expand Down Expand Up @@ -498,7 +501,9 @@ private struct GeminiModelsResponse: Decodable {

public enum GeminiModel {
// Gemini 3.x family (current)
public static let flash36 = "gemini-3.6-flash"
public static let flash35 = "gemini-3.5-flash"
public static let flashLite35 = "gemini-3.5-flash-lite"
public static let flashLite31 = "gemini-3.1-flash-lite"
public static let pro31 = "gemini-3.1-pro"
public static let flash30 = "gemini-3-flash"
Expand All @@ -512,6 +517,15 @@ public enum GeminiModel {

extension GeminiProvider {
public static let curatedModels: [LLMModelInfo] = [
LLMModelInfo(
id: GeminiModel.flash36,
providerName: name,
displayName: "Gemini 3.6 Flash",
capabilities: [.chat, .textGeneration, .streaming, .tools, .vision, .imageInput, .reasoning, .structuredOutput],
categories: [.text, .vision, .multimodal],
releaseStage: .stable,
notes: "Latest Gemini Flash — stronger agentic/multimodal performance at lower cost than 3.5 Flash."
),
LLMModelInfo(
id: GeminiModel.flash35,
providerName: name,
Expand All @@ -521,6 +535,15 @@ extension GeminiProvider {
releaseStage: .stable,
notes: "Stable Gemini 3.x model for agentic and coding workloads."
),
LLMModelInfo(
id: GeminiModel.flashLite35,
providerName: name,
displayName: "Gemini 3.5 Flash-Lite",
capabilities: [.chat, .textGeneration, .streaming, .tools, .vision, .imageInput, .structuredOutput],
categories: [.text, .vision, .multimodal],
releaseStage: .stable,
notes: "Fastest, lowest-cost model in the 3.5 family for high-throughput execution."
),
LLMModelInfo(
id: GeminiModel.flashLite31,
providerName: name,
Expand Down Expand Up @@ -573,7 +596,7 @@ extension GeminiProvider {
),
]

public static func gemini(apiKey: String, model: String = GeminiModel.flash35) -> LLMProviderConfiguration {
public static func gemini(apiKey: String, model: String = GeminiModel.flash36) -> LLMProviderConfiguration {
LLMProviderConfiguration(
name: name,
baseURL: URL(string: "https://generativelanguage.googleapis.com/v1beta")!,
Expand Down
16 changes: 14 additions & 2 deletions Sources/LLMProviderKitOpenAI/OpenAIProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,9 @@ private struct OpenAIModelsResponse: Decodable {
// MARK: - Model constants

public enum OpenAIModel {
// GPT-5.5 family (current flagship)
// GPT-5.6 family (current flagship). `gpt-5.6` is an alias that routes to GPT-5.6 Sol.
public static let gpt56 = "gpt-5.6"
// GPT-5.5 family
public static let gpt55 = "gpt-5.5"
// GPT-5.4 family
public static let gpt54 = "gpt-5.4"
Expand All @@ -305,6 +307,16 @@ public enum OpenAIModel {

extension OpenAIProvider {
public static let curatedModels: [LLMModelInfo] = [
LLMModelInfo(
id: OpenAIModel.gpt56,
providerName: name,
displayName: "GPT-5.6",
contextWindow: 1_000_000,
capabilities: [.chat, .textGeneration, .streaming, .tools, .vision, .imageInput, .reasoning, .structuredOutput],
categories: [.text, .vision, .multimodal],
releaseStage: .stable,
notes: "Current flagship (alias for GPT-5.6 Sol) — most intelligent and token-efficient for complex work."
),
LLMModelInfo(
id: OpenAIModel.gpt55,
providerName: name,
Expand All @@ -313,7 +325,7 @@ extension OpenAIProvider {
capabilities: [.chat, .textGeneration, .streaming, .tools, .vision, .imageInput, .reasoning, .structuredOutput],
categories: [.text, .vision, .multimodal],
releaseStage: .stable,
notes: "Flagship model for complex reasoning and coding."
notes: "Previous flagship for complex reasoning and coding."
),
LLMModelInfo(
id: OpenAIModel.gpt54,
Expand Down
35 changes: 35 additions & 0 deletions Tests/LLMProviderKitTests/LLMKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,41 @@ extension ProviderTests {
#expect(firstMsg["content"] is String)
}

@Test func anthropicSendsValidVersionHeader() async throws {
let provider = AnthropicProvider(configuration: AnthropicProvider.anthropic(apiKey: "test", model: "claude-3-5-sonnet-20241022"))
let request = LLMRequest(model: "claude-3-5-sonnet-20241022", messages: [.user("Hi")])
let urlRequest = try provider.prepareRequest(request, stream: false)

// Anthropic requires a dated version string; the branding string belongs in User-Agent.
#expect(urlRequest.value(forHTTPHeaderField: "anthropic-version") == "2023-06-01")
#expect(urlRequest.value(forHTTPHeaderField: "anthropic-version") != "LLMProviderKit/1.0")
}

@Test func geminiSendsToolResultsAsUserRole() async throws {
let provider = GeminiProvider(configuration: GeminiProvider.gemini(apiKey: "test", model: "gemini-2.5-flash"))
let request = LLMRequest(
model: "gemini-2.5-flash",
messages: [
.user("What time is it?"),
.assistant(content: "", toolCalls: [
LLMToolCall(id: "call_1", name: "current_datetime", arguments: "{}")
]),
.tool("{\"result\":\"noon\"}", toolCallId: "current_datetime")
]
)

let body = try #require(provider.prepareRequest(request, stream: false).httpBody)
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
let contents = try #require(json["contents"] as? [[String: Any]])

// The turn carrying the functionResponse part must use the "user" role.
let functionResponseTurn = try #require(contents.first { turn in
guard let parts = turn["parts"] as? [[String: Any]] else { return false }
return parts.contains { $0["functionResponse"] != nil }
})
#expect(functionResponseTurn["role"] as? String == "user")
}

@Test func ollamaAssistantToolCallsSerializeArgumentsAsJSONObject() async throws {
let ollama = OllamaProvider(configuration: OllamaProvider.local(model: "qwen3:0.6b"))
let request = LLMRequest(
Expand Down
Loading