From 1395e6ef52804c932fdd2e8a99e985999dd543d2 Mon Sep 17 00:00:00 2001
From: Link Dupont
Date: Thu, 26 Mar 2026 12:28:42 -0400
Subject: [PATCH 1/2] fix: Strip additionalProperties from Gemini function
declaration schemas
The Gemini API rejects the additionalProperties field in function
declaration schemas with HTTP 400. Add a GeminiSchema wrapper that
re-encodes JSONSchema identically but omits additionalProperties,
recursively handling nested objects, arrays, and anyOf variants.
---
.../AgentRunKit/LLM/GeminiClientTypes.swift | 63 +++++++++-
.../AgentRunKitTests/GeminiClientTests.swift | 111 ++++++++++++++++++
2 files changed, 172 insertions(+), 2 deletions(-)
diff --git a/Sources/AgentRunKit/LLM/GeminiClientTypes.swift b/Sources/AgentRunKit/LLM/GeminiClientTypes.swift
index 9919a02..44cc0d8 100644
--- a/Sources/AgentRunKit/LLM/GeminiClientTypes.swift
+++ b/Sources/AgentRunKit/LLM/GeminiClientTypes.swift
@@ -91,7 +91,7 @@ struct GeminiTool: Encodable {
struct GeminiFunctionDeclaration: Encodable {
let name: String
let description: String
- let parametersJsonSchema: JSONSchema
+ let parametersJsonSchema: GeminiSchema
enum CodingKeys: String, CodingKey {
case name, description
@@ -101,7 +101,7 @@ struct GeminiFunctionDeclaration: Encodable {
init(_ definition: ToolDefinition) {
name = definition.name
description = definition.description
- parametersJsonSchema = definition.parametersSchema
+ parametersJsonSchema = GeminiSchema(definition.parametersSchema)
}
}
@@ -293,3 +293,62 @@ enum GeminiMessageMapper {
}
}
}
+
+struct GeminiSchema: Encodable {
+ let wrapped: JSONSchema
+
+ init(_ schema: JSONSchema) {
+ wrapped = schema
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type, description, items, properties, required, anyOf
+ case `enum`
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ switch wrapped {
+ case let .string(description, enumValues):
+ try container.encode("string", forKey: .type)
+ try container.encodeIfPresent(description, forKey: .description)
+ try container.encodeIfPresent(enumValues, forKey: .enum)
+
+ case let .integer(description):
+ try container.encode("integer", forKey: .type)
+ try container.encodeIfPresent(description, forKey: .description)
+
+ case let .number(description):
+ try container.encode("number", forKey: .type)
+ try container.encodeIfPresent(description, forKey: .description)
+
+ case let .boolean(description):
+ try container.encode("boolean", forKey: .type)
+ try container.encodeIfPresent(description, forKey: .description)
+
+ case let .array(items, description):
+ try container.encode("array", forKey: .type)
+ try container.encode(GeminiSchema(items), forKey: .items)
+ try container.encodeIfPresent(description, forKey: .description)
+
+ case let .object(properties, required, description):
+ try container.encode("object", forKey: .type)
+ try container.encode(
+ properties.mapValues { GeminiSchema($0) },
+ forKey: .properties
+ )
+ if !required.isEmpty {
+ try container.encode(required, forKey: .required)
+ }
+ try container.encodeIfPresent(description, forKey: .description)
+ // NOTE: intentionally omits `additionalProperties` — unsupported by Gemini API
+
+ case .null:
+ try container.encode("null", forKey: .type)
+
+ case let .anyOf(schemas):
+ try container.encode(schemas.map { GeminiSchema($0) }, forKey: .anyOf)
+ }
+ }
+}
diff --git a/Tests/AgentRunKitTests/GeminiClientTests.swift b/Tests/AgentRunKitTests/GeminiClientTests.swift
index 1844724..939549b 100644
--- a/Tests/AgentRunKitTests/GeminiClientTests.swift
+++ b/Tests/AgentRunKitTests/GeminiClientTests.swift
@@ -864,3 +864,114 @@ private enum TestGeminiOutput: SchemaProviding {
.object(properties: ["value": .string()], required: ["value"])
}
}
+
+// MARK: - GeminiSchema Tests
+
+struct GeminiSchemaTests {
+ /// Recursively asserts that no dictionary in the tree contains the key
+ /// `additionalProperties`.
+ private func assertNoAdditionalProperties(
+ _ value: Any, path: String = "$"
+ ) {
+ if let dict = value as? [String: Any] {
+ if dict["additionalProperties"] != nil {
+ Issue.record("Found additionalProperties at \(path)")
+ }
+ for (key, child) in dict {
+ assertNoAdditionalProperties(child, path: "\(path).\(key)")
+ }
+ } else if let array = value as? [Any] {
+ for (index, child) in array.enumerated() {
+ assertNoAdditionalProperties(child, path: "\(path)[\(index)]")
+ }
+ }
+ }
+
+ @Test
+ func stripsAdditionalPropertiesRecursively() throws {
+ // Exercises every nesting path: top-level object, nested object,
+ // array items, anyOf variants, and deeply nested combinations.
+ let schema = GeminiSchema(
+ .object(
+ properties: [
+ "name": .string(description: "User name"),
+ "age": .integer(description: "Age"),
+ "score": .number(),
+ "active": .boolean(),
+ "role": .string(enumValues: ["admin", "user"]),
+ "address": .object(
+ properties: ["city": .string(), "zip": .string()],
+ required: ["city"]
+ ),
+ "items": .array(items:
+ .object(
+ properties: [
+ "meta": .object(
+ properties: ["key": .string()],
+ required: ["key"]
+ )
+ ],
+ required: ["meta"]
+ )),
+ "optional_field": .anyOf([
+ .object(properties: ["a": .string()], required: ["a"]),
+ .null
+ ])
+ ],
+ required: ["name"],
+ description: "User record"
+ )
+ )
+ let data = try JSONEncoder().encode(schema)
+ let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
+
+ // No additionalProperties anywhere in the tree
+ assertNoAdditionalProperties(json)
+
+ // Spot-check that schema fields are still preserved
+ #expect(json["type"] as? String == "object")
+ #expect(json["description"] as? String == "User record")
+ #expect(json["required"] as? [String] == ["name"])
+
+ let props = json["properties"] as? [String: Any]
+ #expect((props?["name"] as? [String: Any])?["type"] as? String == "string")
+ #expect((props?["age"] as? [String: Any])?["type"] as? String == "integer")
+ #expect((props?["score"] as? [String: Any])?["type"] as? String == "number")
+ #expect((props?["active"] as? [String: Any])?["type"] as? String == "boolean")
+ #expect((props?["role"] as? [String: Any])?["enum"] as? [String] == ["admin", "user"])
+
+ let address = props?["address"] as? [String: Any]
+ #expect(address?["type"] as? String == "object")
+ }
+
+ @Test
+ func geminiRequestToolSchemaOmitsAdditionalProperties() throws {
+ let client = GeminiClient(apiKey: "test-key", model: "gemini-2.5-pro")
+ let tools = [
+ ToolDefinition(
+ name: "create_user",
+ description: "Create a user",
+ parametersSchema: .object(
+ properties: [
+ "name": .string(),
+ "address": .object(
+ properties: ["street": .string(), "city": .string()],
+ required: ["street", "city"]
+ ),
+ "tags": .array(items: .string())
+ ],
+ required: ["name", "address"]
+ )
+ )
+ ]
+ let request = try client.buildRequest(messages: [.user("Hi")], tools: tools)
+ let json = try encodeRequest(request)
+
+ let jsonTools = json["tools"] as? [[String: Any]]
+ let decls = jsonTools?[0]["functionDeclarations"] as? [[String: Any]]
+ let params = try #require(decls?[0]["parameters"] as? [String: Any])
+
+ assertNoAdditionalProperties(params)
+ #expect(params["type"] as? String == "object")
+ }
+}
From ace2f46528b24d8329b64e8babd636ad2e874664 Mon Sep 17 00:00:00 2001
From: Link Dupont
Date: Thu, 26 Mar 2026 13:48:41 -0400
Subject: [PATCH 2/2] fix: Exclude model field from Vertex Anthropic request
body
The Vertex AI rawPredict endpoint specifies the model in the URL path
and rejects it in the request body with 'Extra inputs are not permitted'.
Re-encode the inner AnthropicRequest with model set to nil so
encodeIfPresent skips it.
---
.../AgentRunKit/LLM/VertexAnthropicClient.swift | 14 +++++++++++++-
.../VertexAnthropicClientTests.swift | 2 +-
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/Sources/AgentRunKit/LLM/VertexAnthropicClient.swift b/Sources/AgentRunKit/LLM/VertexAnthropicClient.swift
index c9d6df4..a49530f 100644
--- a/Sources/AgentRunKit/LLM/VertexAnthropicClient.swift
+++ b/Sources/AgentRunKit/LLM/VertexAnthropicClient.swift
@@ -177,7 +177,19 @@ struct VertexAnthropicRequest: Encodable {
let inner: AnthropicRequest
func encode(to encoder: any Encoder) throws {
- try inner.encode(to: encoder)
+ // Re-encode the inner request without the `model` field, which is
+ // specified in the Vertex AI URL path and rejected in the body.
+ let withoutModel = AnthropicRequest(
+ model: nil,
+ messages: inner.messages,
+ system: inner.system,
+ tools: inner.tools,
+ maxTokens: inner.maxTokens,
+ stream: inner.stream,
+ thinking: inner.thinking,
+ extraFields: inner.extraFields
+ )
+ try withoutModel.encode(to: encoder)
var container = encoder.container(keyedBy: DynamicCodingKey.self)
try container.encode(
Self.vertexAnthropicVersion,
diff --git a/Tests/AgentRunKitTests/VertexAnthropicClientTests.swift b/Tests/AgentRunKitTests/VertexAnthropicClientTests.swift
index 5c99637..d3f6939 100644
--- a/Tests/AgentRunKitTests/VertexAnthropicClientTests.swift
+++ b/Tests/AgentRunKitTests/VertexAnthropicClientTests.swift
@@ -122,7 +122,7 @@ struct VertexAnthropicRequestTests {
let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(json["max_tokens"] as? Int == 4096)
- #expect(json["model"] as? String == "claude-sonnet-4-6")
+ #expect(json["model"] == nil, "model must not appear in Vertex request body")
let messages = json["messages"] as? [[String: Any]]
#expect(messages?.count == 1)