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/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/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") + } +} 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)