Skip to content

Commit 5f8e42a

Browse files
authored
Merge pull request #5 from subpop/gemini-tool-call-parameters
Strip additionalProperties from Gemini function declaration schemas
2 parents 597966d + ace2f46 commit 5f8e42a

4 files changed

Lines changed: 186 additions & 4 deletions

File tree

Sources/AgentRunKit/LLM/GeminiClientTypes.swift

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ struct GeminiTool: Encodable {
9191
struct GeminiFunctionDeclaration: Encodable {
9292
let name: String
9393
let description: String
94-
let parametersJsonSchema: JSONSchema
94+
let parametersJsonSchema: GeminiSchema
9595

9696
enum CodingKeys: String, CodingKey {
9797
case name, description
@@ -101,7 +101,7 @@ struct GeminiFunctionDeclaration: Encodable {
101101
init(_ definition: ToolDefinition) {
102102
name = definition.name
103103
description = definition.description
104-
parametersJsonSchema = definition.parametersSchema
104+
parametersJsonSchema = GeminiSchema(definition.parametersSchema)
105105
}
106106
}
107107

@@ -293,3 +293,62 @@ enum GeminiMessageMapper {
293293
}
294294
}
295295
}
296+
297+
struct GeminiSchema: Encodable {
298+
let wrapped: JSONSchema
299+
300+
init(_ schema: JSONSchema) {
301+
wrapped = schema
302+
}
303+
304+
private enum CodingKeys: String, CodingKey {
305+
case type, description, items, properties, required, anyOf
306+
case `enum`
307+
}
308+
309+
func encode(to encoder: any Encoder) throws {
310+
var container = encoder.container(keyedBy: CodingKeys.self)
311+
312+
switch wrapped {
313+
case let .string(description, enumValues):
314+
try container.encode("string", forKey: .type)
315+
try container.encodeIfPresent(description, forKey: .description)
316+
try container.encodeIfPresent(enumValues, forKey: .enum)
317+
318+
case let .integer(description):
319+
try container.encode("integer", forKey: .type)
320+
try container.encodeIfPresent(description, forKey: .description)
321+
322+
case let .number(description):
323+
try container.encode("number", forKey: .type)
324+
try container.encodeIfPresent(description, forKey: .description)
325+
326+
case let .boolean(description):
327+
try container.encode("boolean", forKey: .type)
328+
try container.encodeIfPresent(description, forKey: .description)
329+
330+
case let .array(items, description):
331+
try container.encode("array", forKey: .type)
332+
try container.encode(GeminiSchema(items), forKey: .items)
333+
try container.encodeIfPresent(description, forKey: .description)
334+
335+
case let .object(properties, required, description):
336+
try container.encode("object", forKey: .type)
337+
try container.encode(
338+
properties.mapValues { GeminiSchema($0) },
339+
forKey: .properties
340+
)
341+
if !required.isEmpty {
342+
try container.encode(required, forKey: .required)
343+
}
344+
try container.encodeIfPresent(description, forKey: .description)
345+
// NOTE: intentionally omits `additionalProperties` — unsupported by Gemini API
346+
347+
case .null:
348+
try container.encode("null", forKey: .type)
349+
350+
case let .anyOf(schemas):
351+
try container.encode(schemas.map { GeminiSchema($0) }, forKey: .anyOf)
352+
}
353+
}
354+
}

Sources/AgentRunKit/LLM/VertexAnthropicClient.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,19 @@ struct VertexAnthropicRequest: Encodable {
177177
let inner: AnthropicRequest
178178

179179
func encode(to encoder: any Encoder) throws {
180-
try inner.encode(to: encoder)
180+
// Re-encode the inner request without the `model` field, which is
181+
// specified in the Vertex AI URL path and rejected in the body.
182+
let withoutModel = AnthropicRequest(
183+
model: nil,
184+
messages: inner.messages,
185+
system: inner.system,
186+
tools: inner.tools,
187+
maxTokens: inner.maxTokens,
188+
stream: inner.stream,
189+
thinking: inner.thinking,
190+
extraFields: inner.extraFields
191+
)
192+
try withoutModel.encode(to: encoder)
181193
var container = encoder.container(keyedBy: DynamicCodingKey.self)
182194
try container.encode(
183195
Self.vertexAnthropicVersion,

Tests/AgentRunKitTests/GeminiClientTests.swift

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,3 +864,114 @@ private enum TestGeminiOutput: SchemaProviding {
864864
.object(properties: ["value": .string()], required: ["value"])
865865
}
866866
}
867+
868+
// MARK: - GeminiSchema Tests
869+
870+
struct GeminiSchemaTests {
871+
/// Recursively asserts that no dictionary in the tree contains the key
872+
/// `additionalProperties`.
873+
private func assertNoAdditionalProperties(
874+
_ value: Any, path: String = "$"
875+
) {
876+
if let dict = value as? [String: Any] {
877+
if dict["additionalProperties"] != nil {
878+
Issue.record("Found additionalProperties at \(path)")
879+
}
880+
for (key, child) in dict {
881+
assertNoAdditionalProperties(child, path: "\(path).\(key)")
882+
}
883+
} else if let array = value as? [Any] {
884+
for (index, child) in array.enumerated() {
885+
assertNoAdditionalProperties(child, path: "\(path)[\(index)]")
886+
}
887+
}
888+
}
889+
890+
@Test
891+
func stripsAdditionalPropertiesRecursively() throws {
892+
// Exercises every nesting path: top-level object, nested object,
893+
// array items, anyOf variants, and deeply nested combinations.
894+
let schema = GeminiSchema(
895+
.object(
896+
properties: [
897+
"name": .string(description: "User name"),
898+
"age": .integer(description: "Age"),
899+
"score": .number(),
900+
"active": .boolean(),
901+
"role": .string(enumValues: ["admin", "user"]),
902+
"address": .object(
903+
properties: ["city": .string(), "zip": .string()],
904+
required: ["city"]
905+
),
906+
"items": .array(items:
907+
.object(
908+
properties: [
909+
"meta": .object(
910+
properties: ["key": .string()],
911+
required: ["key"]
912+
)
913+
],
914+
required: ["meta"]
915+
)),
916+
"optional_field": .anyOf([
917+
.object(properties: ["a": .string()], required: ["a"]),
918+
.null
919+
])
920+
],
921+
required: ["name"],
922+
description: "User record"
923+
)
924+
)
925+
let data = try JSONEncoder().encode(schema)
926+
let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
927+
928+
// No additionalProperties anywhere in the tree
929+
assertNoAdditionalProperties(json)
930+
931+
// Spot-check that schema fields are still preserved
932+
#expect(json["type"] as? String == "object")
933+
#expect(json["description"] as? String == "User record")
934+
#expect(json["required"] as? [String] == ["name"])
935+
936+
let props = json["properties"] as? [String: Any]
937+
#expect((props?["name"] as? [String: Any])?["type"] as? String == "string")
938+
#expect((props?["age"] as? [String: Any])?["type"] as? String == "integer")
939+
#expect((props?["score"] as? [String: Any])?["type"] as? String == "number")
940+
#expect((props?["active"] as? [String: Any])?["type"] as? String == "boolean")
941+
#expect((props?["role"] as? [String: Any])?["enum"] as? [String] == ["admin", "user"])
942+
943+
let address = props?["address"] as? [String: Any]
944+
#expect(address?["type"] as? String == "object")
945+
}
946+
947+
@Test
948+
func geminiRequestToolSchemaOmitsAdditionalProperties() throws {
949+
let client = GeminiClient(apiKey: "test-key", model: "gemini-2.5-pro")
950+
let tools = [
951+
ToolDefinition(
952+
name: "create_user",
953+
description: "Create a user",
954+
parametersSchema: .object(
955+
properties: [
956+
"name": .string(),
957+
"address": .object(
958+
properties: ["street": .string(), "city": .string()],
959+
required: ["street", "city"]
960+
),
961+
"tags": .array(items: .string())
962+
],
963+
required: ["name", "address"]
964+
)
965+
)
966+
]
967+
let request = try client.buildRequest(messages: [.user("Hi")], tools: tools)
968+
let json = try encodeRequest(request)
969+
970+
let jsonTools = json["tools"] as? [[String: Any]]
971+
let decls = jsonTools?[0]["functionDeclarations"] as? [[String: Any]]
972+
let params = try #require(decls?[0]["parameters"] as? [String: Any])
973+
974+
assertNoAdditionalProperties(params)
975+
#expect(params["type"] as? String == "object")
976+
}
977+
}

Tests/AgentRunKitTests/VertexAnthropicClientTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ struct VertexAnthropicRequestTests {
122122
let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
123123

124124
#expect(json["max_tokens"] as? Int == 4096)
125-
#expect(json["model"] as? String == "claude-sonnet-4-6")
125+
#expect(json["model"] == nil, "model must not appear in Vertex request body")
126126

127127
let messages = json["messages"] as? [[String: Any]]
128128
#expect(messages?.count == 1)

0 commit comments

Comments
 (0)