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
63 changes: 61 additions & 2 deletions Sources/AgentRunKit/LLM/GeminiClientTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -101,7 +101,7 @@ struct GeminiFunctionDeclaration: Encodable {
init(_ definition: ToolDefinition) {
name = definition.name
description = definition.description
parametersJsonSchema = definition.parametersSchema
parametersJsonSchema = GeminiSchema(definition.parametersSchema)
}
}

Expand Down Expand Up @@ -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)
}
}
}
14 changes: 13 additions & 1 deletion Sources/AgentRunKit/LLM/VertexAnthropicClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions Tests/AgentRunKitTests/GeminiClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
2 changes: 1 addition & 1 deletion Tests/AgentRunKitTests/VertexAnthropicClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down