From a8343c542a6f5aaea6b9984f5dee6917d78baa0e Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Mon, 27 Jul 2026 07:40:52 +0300 Subject: [PATCH] Fix Anthropic/Gemini providers and add latest curated models - Anthropic: send valid `anthropic-version: 2023-06-01` header (was a branding string that Anthropic rejects with HTTP 400); move branding to User-Agent. - Gemini: send tool results (functionResponse parts) in a `user` turn instead of `model`; Gemini only accepts user/model roles. - Add regression tests for both fixes. - Curated models: add OpenAI GPT-5.6, Gemini 3.6 Flash and 3.5 Flash-Lite, Anthropic Opus 4.7/4.6; default Gemini preset -> 3.6 Flash. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 +++ .../AnthropicProvider.swift | 25 ++++++++++++- .../LLMProviderKitGemini/GeminiProvider.swift | 29 +++++++++++++-- .../LLMProviderKitOpenAI/OpenAIProvider.swift | 16 +++++++-- Tests/LLMProviderKitTests/LLMKitTests.swift | 35 +++++++++++++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98708a7..e383a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/LLMProviderKitAnthropic/AnthropicProvider.swift b/Sources/LLMProviderKitAnthropic/AnthropicProvider.swift index 875e6e0..dd5dfe6 100644 --- a/Sources/LLMProviderKitAnthropic/AnthropicProvider.swift +++ b/Sources/LLMProviderKitAnthropic/AnthropicProvider.swift @@ -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 @@ -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 @@ -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, diff --git a/Sources/LLMProviderKitGemini/GeminiProvider.swift b/Sources/LLMProviderKitGemini/GeminiProvider.swift index f815cc3..f500908 100644 --- a/Sources/LLMProviderKitGemini/GeminiProvider.swift +++ b/Sources/LLMProviderKitGemini/GeminiProvider.swift @@ -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" } } @@ -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 { @@ -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" @@ -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, @@ -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, @@ -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")!, diff --git a/Sources/LLMProviderKitOpenAI/OpenAIProvider.swift b/Sources/LLMProviderKitOpenAI/OpenAIProvider.swift index abb4579..7f426e0 100644 --- a/Sources/LLMProviderKitOpenAI/OpenAIProvider.swift +++ b/Sources/LLMProviderKitOpenAI/OpenAIProvider.swift @@ -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" @@ -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, @@ -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, diff --git a/Tests/LLMProviderKitTests/LLMKitTests.swift b/Tests/LLMProviderKitTests/LLMKitTests.swift index afffff1..02edb58 100644 --- a/Tests/LLMProviderKitTests/LLMKitTests.swift +++ b/Tests/LLMProviderKitTests/LLMKitTests.swift @@ -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(