diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f7c95..ef665ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,11 @@ retains ownership of its resource API root, `api-key` authentication, and deployment profiles; `ChatClient` and the legacy `Client` alias remain for Chat Completions deployments. +- Add a first-class xAI Responses provider and client with a separate explicit + Chat Completions compatibility pair. The Responses dialect models filtered + web search, X search, code execution, collection search, and keyed remote MCP + servers as typed provider-managed tools, with bounded configuration checks + and an isolated `.xai` extension body. - Record and replay a redacted real Azure OpenAI `gpt-4o` Responses function tool loop, normalizing the resource endpoint and omitting authentication. - Record and replay a redacted real Claude Sonnet 4.6 Converse tool loop. The diff --git a/README.md b/README.md index 8681136..905343b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ conversation. - One agent API across supported providers. - Buffered and streaming responses. - Tool calls, including parallel calls and typed Zig functions. -- Provider-managed web search and fetch behind model capability checks. +- Provider-managed search, fetch, code execution, file search, and remote MCP. - Images, audio, documents, binary data, and provider file references. - Static and per-step dynamic toolsets with namespaces and metadata. - Eager and on-demand capability bundles with dependencies and scoped composition. @@ -181,6 +181,7 @@ authentication style, or model profile overrides. | `zigai.providers.google` | Gemini GenerateContent | | `zigai.providers.azure_openai` | Azure OpenAI v1 Responses and Chat Completions | | `zigai.providers.bedrock` | Amazon Bedrock Converse; Mantle Chat Completions | +| `zigai.providers.xai` | xAI Responses; explicit Chat Completions compatibility | | `zigai.providers.cerebras` | Cerebras Inference | | `zigai.providers.cohere` | Cohere Compatibility API | | `zigai.providers.deepseek` | DeepSeek | diff --git a/docs/api.md b/docs/api.md index ddd1f01..ef36ae9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -206,6 +206,23 @@ which require a chunk-stream transport rather than the line-stream interface. `bedrock.MantleProvider`, `bedrock.MantleClient`, and `bedrock.mantleApiBase` remain available for Bedrock's OpenAI-compatible Chat Completions endpoint. +xAI uses a first-class Responses client and keeps compatibility explicit: + +```zig +var provider = zigai.providers.xai.Provider.init(api_key, transport); +var client = zigai.providers.xai.Client{ + .model_name = "grok-4.6", + .provider = provider.provider(), +}; +``` + +The native client accepts typed web and X search configuration, provider-side +code execution, collection search, and multiple remote MCP servers. Remote MCP +servers are keyed by `server_label`; other provider-managed tools are +singletons. Use `xai.ChatProvider` with `xai.ChatClient` only when an existing +Chat Completions integration requires that compatibility protocol. xAI-only +extension JSON uses the `.xai` tag and cannot cross into another adapter. + OpenAI-compatible modules follow the same rule. Each named module exports a matching `Provider` and `Client` built from shared compile-time defaults. The provider owns the API root, provider identity, authentication style, headers, diff --git a/docs/errors.md b/docs/errors.md index 88efc6e..c7fb9c9 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -39,6 +39,10 @@ cases relevant to the application, then propagate the remainder. | `ModelDoesNotSupportVideo` | Video was supplied to a model profile without video support. | | `ModelDoesNotSupportWebFetch` | Native web fetch is unsupported. | | `ModelDoesNotSupportWebSearch` | Native web search is unsupported. | +| `ModelDoesNotSupportXSearch` | Native X search is unsupported. | +| `ModelDoesNotSupportCodeExecution` | Provider-managed code execution is unsupported. | +| `ModelDoesNotSupportFileSearch` | Provider-managed file search is unsupported. | +| `ModelDoesNotSupportRemoteMcp` | Provider-managed remote MCP is unsupported. | | `ModelDoesNotSupportJsonObjectOutput` | JSON-object output is unsupported. | | `ModelDoesNotSupportJsonSchemaOutput` | JSON Schema output is unsupported. | | `ModelDoesNotSupportMaxTokens` | The model rejects `max_tokens`. | @@ -77,7 +81,7 @@ cases relevant to the application, then propagate the remainder. | Error | Meaning | | --- | --- | | `DuplicateToolName` | Two local tools have the same provider-visible name. | -| `DuplicateBuiltinTool` | A provider-managed tool kind was registered twice. | +| `DuplicateBuiltinTool` | A provider-managed tool identity was registered twice. | | `DuplicateCapabilityId` | Two composed capability scopes declare the same stable ID. | | `InvalidCapability` | A capability ID, metadata list, dependency, conflict, limit, or active-state declaration is invalid. | | `MissingCapabilityDependency` | A dependency ID is absent from the composed capability registry. | diff --git a/src/agent.zig b/src/agent.zig index 33ee1aa..2c1a6c1 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -41,7 +41,7 @@ const AgentError = error{ ContextToolsTooLarge, /// Two locally available tools have the same provider-visible name. DuplicateToolName, - /// Two provider-managed tools have the same kind. + /// Two provider-managed tools have the same provider-visible identity. DuplicateBuiltinTool, /// Two named capabilities in the composed scopes have the same ID. DuplicateCapabilityId, @@ -91,6 +91,14 @@ const AgentError = error{ ModelDoesNotSupportWebFetch, /// The selected model profile does not support native web search. ModelDoesNotSupportWebSearch, + /// The selected model profile does not support native X search. + ModelDoesNotSupportXSearch, + /// The selected model profile does not support provider-managed code execution. + ModelDoesNotSupportCodeExecution, + /// The selected model profile does not support provider-managed file search. + ModelDoesNotSupportFileSearch, + /// The selected model profile does not support provider-managed remote MCP. + ModelDoesNotSupportRemoteMcp, /// JSON-object output was requested from an unsupported model profile. ModelDoesNotSupportJsonObjectOutput, /// JSON Schema output was requested from an unsupported model profile. @@ -4924,12 +4932,16 @@ fn ensureBuiltinToolsSupported( for (tools, 0..) |tool, index| { const kind = tool.kind(); for (tools[index + 1 ..]) |other| { - if (kind == other.kind()) return Agent.Error.DuplicateBuiltinTool; + if (tool.conflictsWith(other)) return Agent.Error.DuplicateBuiltinTool; } if (profile.supportsBuiltinTool(kind)) continue; return switch (kind) { .web_search => Agent.Error.ModelDoesNotSupportWebSearch, .web_fetch => Agent.Error.ModelDoesNotSupportWebFetch, + .x_search => Agent.Error.ModelDoesNotSupportXSearch, + .code_execution => Agent.Error.ModelDoesNotSupportCodeExecution, + .file_search => Agent.Error.ModelDoesNotSupportFileSearch, + .remote_mcp => Agent.Error.ModelDoesNotSupportRemoteMcp, }; } } @@ -5771,6 +5783,41 @@ test "agent private helpers cover ownership settings retries and rich content" { Agent.Error.ModelDoesNotSupportWebSearch, ensureBuiltinToolsSupported(.{}, &.{.{ .web_search = .{} }}), ); + try std.testing.expectError( + Agent.Error.ModelDoesNotSupportWebFetch, + ensureBuiltinToolsSupported(.{}, &.{.{ .web_fetch = .{} }}), + ); + try std.testing.expectError( + Agent.Error.ModelDoesNotSupportXSearch, + ensureBuiltinToolsSupported(.{}, &.{.{ .x_search = .{} }}), + ); + try std.testing.expectError( + Agent.Error.ModelDoesNotSupportCodeExecution, + ensureBuiltinToolsSupported(.{}, &.{.{ .code_execution = .{} }}), + ); + try std.testing.expectError( + Agent.Error.ModelDoesNotSupportFileSearch, + ensureBuiltinToolsSupported(.{}, &.{.{ .file_search = .{ .vector_store_ids = &.{"collection"} } }}), + ); + try std.testing.expectError( + Agent.Error.ModelDoesNotSupportRemoteMcp, + ensureBuiltinToolsSupported(.{}, &.{.{ .remote_mcp = .{ .server_url = "https://example.test", .server_label = "one" } }}), + ); + try ensureBuiltinToolsSupported(.{ + .builtin_tools = model_types.ModelProfile.BuiltinToolSet.initMany(&.{.remote_mcp}), + }, &.{ + .{ .remote_mcp = .{ .server_url = "https://one.example", .server_label = "one" } }, + .{ .remote_mcp = .{ .server_url = "https://two.example", .server_label = "two" } }, + }); + try std.testing.expectError( + Agent.Error.DuplicateBuiltinTool, + ensureBuiltinToolsSupported(.{ + .builtin_tools = model_types.ModelProfile.BuiltinToolSet.initMany(&.{.remote_mcp}), + }, &.{ + .{ .remote_mcp = .{ .server_url = "https://one.example", .server_label = "same" } }, + .{ .remote_mcp = .{ .server_url = "https://two.example", .server_label = "same" } }, + }), + ); const content = model_types.Content{ .source = .{ .bytes = "x" }, .media_type = "application/octet-stream" }; try ensurePromptPartsSupported(selected_model, .{}, &.{ .{ .image = content }, diff --git a/src/model.zig b/src/model.zig index b1d329c..754b9f4 100644 --- a/src/model.zig +++ b/src/model.zig @@ -479,6 +479,10 @@ pub const ContentType = enum { pub const BuiltinToolKind = enum { web_search, web_fetch, + x_search, + code_execution, + file_search, + remote_mcp, }; /// A provider-managed tool. The provider executes it inside the model request; @@ -486,13 +490,53 @@ pub const BuiltinToolKind = enum { pub const BuiltinTool = union(BuiltinToolKind) { web_search: WebSearch, web_fetch: WebFetch, - - pub const WebSearch = struct {}; + x_search: XSearch, + code_execution: CodeExecution, + file_search: FileSearch, + remote_mcp: RemoteMcp, + + pub const WebSearch = struct { + allowed_domains: ?[]const []const u8 = null, + excluded_domains: ?[]const []const u8 = null, + enable_image_understanding: ?bool = null, + enable_image_search: ?bool = null, + }; pub const WebFetch = struct {}; + pub const XSearch = struct { + allowed_x_handles: ?[]const []const u8 = null, + excluded_x_handles: ?[]const []const u8 = null, + from_date: ?[]const u8 = null, + to_date: ?[]const u8 = null, + enable_image_understanding: ?bool = null, + enable_video_understanding: ?bool = null, + }; + pub const CodeExecution = struct {}; + pub const FileSearch = struct { + vector_store_ids: []const []const u8, + max_num_results: ?u32 = null, + }; + pub const RemoteMcp = struct { + server_url: []const u8, + server_label: []const u8, + server_description: ?[]const u8 = null, + allowed_tools: ?[]const []const u8 = null, + authorization: ?[]const u8 = null, + headers: ?[]const RequestHeader = null, + }; pub fn kind(self: BuiltinTool) BuiltinToolKind { return std.meta.activeTag(self); } + + /// Whether two declarations would create the same provider-visible tool. + /// Remote MCP servers are keyed by label; all other built-ins are singletons. + pub fn conflictsWith(self: BuiltinTool, other: BuiltinTool) bool { + if (self.kind() != other.kind()) return false; + return switch (self) { + .remote_mcp => |server| std.mem.eql(u8, server.server_label, other.remote_mcp.server_label), + else => true, + }; + } }; pub const ReasoningEffort = settings_types.ReasoningEffort; diff --git a/src/providers.zig b/src/providers.zig index bd5b034..b6320c7 100644 --- a/src/providers.zig +++ b/src/providers.zig @@ -28,6 +28,7 @@ pub const openrouter = @import("providers/openrouter.zig"); pub const ovhcloud = @import("providers/ovhcloud.zig"); pub const pydantic_gateway = @import("providers/pydantic_gateway.zig"); pub const together = @import("providers/together.zig"); +pub const xai = @import("providers/xai.zig"); const TestTransport = struct { calls: usize = 0, @@ -59,6 +60,7 @@ test { _ = ovhcloud; _ = pydantic_gateway; _ = together; + _ = xai; } fn namedCompatibleProfile(comptime ProviderType: type, comptime ClientType: type, model_name: []const u8) model.ModelProfile { @@ -85,6 +87,7 @@ test "named compatible clients use their provider model profiles" { try std.testing.expect(namedCompatibleProfile(ovhcloud.Provider, ovhcloud.Client, "qwen-3").supports_tools); try std.testing.expect(namedCompatibleProfile(pydantic_gateway.Provider, pydantic_gateway.Client, "openai:gpt-4o").supports_json_object_output); try std.testing.expect(namedCompatibleProfile(together.Provider, together.Client, "openai/gpt-oss-20b").supportsReasoningEffort(.low)); + try std.testing.expect(namedCompatibleProfile(xai.ChatProvider, xai.ChatClient, "grok-4.6").supports_tools); const unknown = namedCompatibleProfile(groq.Provider, groq.Client, "future-model"); try std.testing.expect(!unknown.supports_tools); diff --git a/src/providers/anthropic.zig b/src/providers/anthropic.zig index 94ec3eb..03eb63d 100644 --- a/src/providers/anthropic.zig +++ b/src/providers/anthropic.zig @@ -20,6 +20,8 @@ pub const Error = model_types.ProviderRequestError || error{ InvalidRequestEncoding, /// A requested rich-content kind has no Anthropic representation. UnsupportedContentType, + /// A requested provider-managed tool has no Anthropic representation. + UnsupportedBuiltinTool, /// The requested structured-output mode has no Anthropic representation. UnsupportedOutputMode, }; @@ -844,6 +846,7 @@ fn encodeRequestForProvider(allocator: std.mem.Allocator, provider_name: []const try json.objectField("name"); try json.write("web_fetch"); }, + else => return error.UnsupportedBuiltinTool, } try json.endObject(); } diff --git a/src/providers/google.zig b/src/providers/google.zig index 0c75c7b..7ec2fae 100644 --- a/src/providers/google.zig +++ b/src/providers/google.zig @@ -20,6 +20,8 @@ pub const Error = model_types.ProviderRequestError || error{ InvalidRequestEncoding, /// Provider-neutral rich content cannot be represented by Gemini. UnsupportedContentType, + /// A requested provider-managed tool has no Gemini representation. + UnsupportedBuiltinTool, }; /// Google Generative Language provider state. Credentials and HTTP policy are @@ -556,6 +558,7 @@ fn encodeRequestForProvider(allocator: std.mem.Allocator, provider_name: []const try json.objectField(switch (tool) { .web_search => "googleSearch", .web_fetch => "urlContext", + else => return error.UnsupportedBuiltinTool, }); try json.beginObject(); try json.endObject(); diff --git a/src/providers/openai.zig b/src/providers/openai.zig index e8f3367..457eb37 100644 --- a/src/providers/openai.zig +++ b/src/providers/openai.zig @@ -23,6 +23,12 @@ pub const Error = model_types.ProviderRequestError || error{ UnsupportedContentType, }; +/// Provider extensions to the shared Responses API tool vocabulary. +pub const Dialect = enum { + openai, + xai, +}; + /// OpenAI provider state. Credentials and HTTP configuration remain here and /// are never exposed to `Client` or its wire encoder. pub const Provider = struct { @@ -100,6 +106,8 @@ pub const Client = struct { model_name: []const u8, provider: provider_types.Provider, settings: model_types.ModelSettings = .{}, + dialect: Dialect = .openai, + extra_body_kind: model_types.ExtraBodyKind = .openai, profile: model_types.ModelProfile = .{ .supports_tools = true, .supports_parallel_tool_calls = true, @@ -125,7 +133,7 @@ pub const Client = struct { pub fn model(self: *Client) model_types.Model { var resolved_profile = self.provider.modelProfile(self.model_name, self.profile); - resolved_profile.extra_body_kind = .openai; + resolved_profile.extra_body_kind = self.extra_body_kind; return .{ .context = self, .profile = resolved_profile, @@ -139,7 +147,14 @@ pub const Client = struct { fn request(context: *anyopaque, allocator: std.mem.Allocator, value: model_types.ModelRequest) !model_types.ModelResponse { const self: *Client = @ptrCast(@alignCast(context)); - const body = try encodeRequestForProvider(allocator, self.provider.name, self.model_name, value); + const body = try encodeRequestForProvider( + allocator, + self.provider.name, + self.model_name, + value, + self.dialect, + self.extra_body_kind, + ); defer allocator.free(body); var headers: std.ArrayList(http.Header) = .empty; defer headers.deinit(allocator); @@ -172,7 +187,14 @@ pub const Client = struct { fn stream(context: *anyopaque, allocator: std.mem.Allocator, value: model_types.ModelRequest, sink: model_types.ModelStreamSink) !model_types.ModelResponse { const self: *Client = @ptrCast(@alignCast(context)); - const body = try encodeStreamingRequestForProvider(allocator, self.provider.name, self.model_name, value); + const body = try encodeStreamingRequestForProvider( + allocator, + self.provider.name, + self.model_name, + value, + self.dialect, + self.extra_body_kind, + ); defer allocator.free(body); var headers: std.ArrayList(http.Header) = .empty; defer headers.deinit(allocator); @@ -314,11 +336,25 @@ test "OpenAI provider owns identity and model profile overrides" { } pub fn encodeStreamingRequest(allocator: std.mem.Allocator, model_name: []const u8, request: model_types.ModelRequest) ![]u8 { - return encodeStreamingRequestForProvider(allocator, "openai", model_name, request); + return encodeStreamingRequestForProvider(allocator, "openai", model_name, request, .openai, .openai); } -fn encodeStreamingRequestForProvider(allocator: std.mem.Allocator, provider_name: []const u8, model_name: []const u8, request: model_types.ModelRequest) ![]u8 { - const buffered = try encodeRequestForProvider(allocator, provider_name, model_name, request); +fn encodeStreamingRequestForProvider( + allocator: std.mem.Allocator, + provider_name: []const u8, + model_name: []const u8, + request: model_types.ModelRequest, + dialect: Dialect, + extra_body_kind: model_types.ExtraBodyKind, +) ![]u8 { + const buffered = try encodeRequestForProvider( + allocator, + provider_name, + model_name, + request, + dialect, + extra_body_kind, + ); defer allocator.free(buffered); if (buffered.len == 0 or buffered[buffered.len - 1] != '}') return error.InvalidRequestEncoding; return std.fmt.allocPrint(allocator, "{s},\"stream\":true}}", .{buffered[0 .. buffered.len - 1]}); @@ -478,10 +514,17 @@ const StreamState = struct { }; pub fn encodeRequest(allocator: std.mem.Allocator, model_name: []const u8, request: model_types.ModelRequest) ![]u8 { - return encodeRequestForProvider(allocator, "openai", model_name, request); + return encodeRequestForProvider(allocator, "openai", model_name, request, .openai, .openai); } -fn encodeRequestForProvider(allocator: std.mem.Allocator, provider_name: []const u8, model_name: []const u8, request: model_types.ModelRequest) ![]u8 { +fn encodeRequestForProvider( + allocator: std.mem.Allocator, + provider_name: []const u8, + model_name: []const u8, + request: model_types.ModelRequest, + dialect: Dialect, + extra_body_kind: model_types.ExtraBodyKind, +) ![]u8 { request.settings.validate() catch return error.InvalidRequestEncoding; try common.validateToolChoice(request.tools, request.builtin_tools.len, request.settings.tool_choice); var output: std.Io.Writer.Allocating = .init(allocator); @@ -578,15 +621,7 @@ fn encodeRequestForProvider(allocator: std.mem.Allocator, provider_name: []const if (request.tools.len > 0 or request.builtin_tools.len > 0) { try json.objectField("tools"); try json.beginArray(); - for (request.builtin_tools) |tool| switch (tool) { - .web_search => { - try json.beginObject(); - try json.objectField("type"); - try json.write("web_search"); - try json.endObject(); - }, - .web_fetch => return error.UnsupportedBuiltinTool, - }; + for (request.builtin_tools) |tool| try writeBuiltinTool(&json, tool, dialect); for (request.tools) |tool| { try json.beginObject(); try json.objectField("type"); @@ -669,7 +704,7 @@ fn encodeRequestForProvider(allocator: std.mem.Allocator, provider_name: []const allocator, &json, request.settings.extra_body, - .openai, + extra_body_kind, &.{ "model", "instructions", @@ -694,6 +729,184 @@ fn encodeRequestForProvider(allocator: std.mem.Allocator, provider_name: []const return output.toOwnedSlice(); } +fn writeBuiltinTool(json: *std.json.Stringify, tool: model_types.BuiltinTool, dialect: Dialect) !void { + if (dialect == .xai) try validateXaiBuiltinTool(tool); + switch (tool) { + .web_search => |search| { + if (dialect == .openai and (search.allowed_domains != null or search.excluded_domains != null or + search.enable_image_understanding != null or search.enable_image_search != null)) + return error.UnsupportedBuiltinTool; + try json.beginObject(); + try json.objectField("type"); + try json.write("web_search"); + if (search.allowed_domains != null or search.excluded_domains != null) { + try json.objectField("filters"); + try json.beginObject(); + if (search.allowed_domains) |domains| { + try json.objectField("allowed_domains"); + try json.write(domains); + } + if (search.excluded_domains) |domains| { + try json.objectField("excluded_domains"); + try json.write(domains); + } + try json.endObject(); + } + if (search.enable_image_understanding) |enabled| { + try json.objectField("enable_image_understanding"); + try json.write(enabled); + } + if (search.enable_image_search) |enabled| { + try json.objectField("enable_image_search"); + try json.write(enabled); + } + try json.endObject(); + }, + .web_fetch => return error.UnsupportedBuiltinTool, + .x_search => |search| { + if (dialect != .xai) return error.UnsupportedBuiltinTool; + try json.beginObject(); + try json.objectField("type"); + try json.write("x_search"); + if (search.allowed_x_handles) |handles| { + try json.objectField("allowed_x_handles"); + try json.write(handles); + } + if (search.excluded_x_handles) |handles| { + try json.objectField("excluded_x_handles"); + try json.write(handles); + } + if (search.from_date) |date| { + try json.objectField("from_date"); + try json.write(date); + } + if (search.to_date) |date| { + try json.objectField("to_date"); + try json.write(date); + } + if (search.enable_image_understanding) |enabled| { + try json.objectField("enable_image_understanding"); + try json.write(enabled); + } + if (search.enable_video_understanding) |enabled| { + try json.objectField("enable_video_understanding"); + try json.write(enabled); + } + try json.endObject(); + }, + .code_execution => { + if (dialect != .xai) return error.UnsupportedBuiltinTool; + try json.write(.{ .type = "code_interpreter" }); + }, + .file_search => |search| { + if (dialect != .xai) return error.UnsupportedBuiltinTool; + try json.beginObject(); + try json.objectField("type"); + try json.write("file_search"); + try json.objectField("vector_store_ids"); + try json.write(search.vector_store_ids); + if (search.max_num_results) |maximum| { + try json.objectField("max_num_results"); + try json.write(maximum); + } + try json.endObject(); + }, + .remote_mcp => |server| { + if (dialect != .xai) return error.UnsupportedBuiltinTool; + try json.beginObject(); + try json.objectField("type"); + try json.write("mcp"); + try json.objectField("server_url"); + try json.write(server.server_url); + try json.objectField("server_label"); + try json.write(server.server_label); + if (server.server_description) |description| { + try json.objectField("server_description"); + try json.write(description); + } + if (server.allowed_tools) |allowed| { + try json.objectField("allowed_tools"); + try json.write(allowed); + } + if (server.authorization) |authorization| { + try json.objectField("authorization"); + try json.write(authorization); + } + if (server.headers) |headers| { + try json.objectField("headers"); + try json.beginObject(); + for (headers) |header| { + try json.objectField(header.name); + try json.write(header.value); + } + try json.endObject(); + } + try json.endObject(); + }, + } +} + +fn validateXaiBuiltinTool(tool: model_types.BuiltinTool) !void { + switch (tool) { + .web_search => |search| { + if (search.allowed_domains != null and search.excluded_domains != null) + return error.InvalidRequestEncoding; + try validateNames(search.allowed_domains, 5); + try validateNames(search.excluded_domains, 5); + }, + .web_fetch => {}, + .x_search => |search| { + if (search.allowed_x_handles != null and search.excluded_x_handles != null) + return error.InvalidRequestEncoding; + try validateNames(search.allowed_x_handles, 20); + try validateNames(search.excluded_x_handles, 20); + if (search.from_date) |date| if (!validDate(date)) return error.InvalidRequestEncoding; + if (search.to_date) |date| if (!validDate(date)) return error.InvalidRequestEncoding; + if (search.from_date != null and search.to_date != null and + std.mem.order(u8, search.from_date.?, search.to_date.?) == .gt) + return error.InvalidRequestEncoding; + }, + .code_execution => {}, + .file_search => |search| { + try validateNames(search.vector_store_ids, std.math.maxInt(usize)); + if (search.vector_store_ids.len == 0 or search.max_num_results == 0) + return error.InvalidRequestEncoding; + }, + .remote_mcp => |server| { + if (!std.mem.startsWith(u8, server.server_url, "https://") or server.server_label.len == 0) + return error.InvalidRequestEncoding; + try validateNames(server.allowed_tools, std.math.maxInt(usize)); + if (server.authorization) |authorization| if (authorization.len == 0) + return error.InvalidRequestEncoding; + if (server.headers) |headers| for (headers, 0..) |header, index| { + if (header.name.len == 0 or header.value.len == 0 or + std.mem.indexOfAny(u8, header.name, "\r\n") != null or + std.mem.indexOfAny(u8, header.value, "\r\n") != null) + return error.InvalidRequestEncoding; + for (headers[index + 1 ..]) |other| if (std.ascii.eqlIgnoreCase(header.name, other.name)) + return error.InvalidRequestEncoding; + }; + }, + } +} + +fn validateNames(values: ?[]const []const u8, maximum: usize) !void { + const names = values orelse return; + if (names.len > maximum) return error.InvalidRequestEncoding; + for (names) |name| if (name.len == 0) return error.InvalidRequestEncoding; +} + +fn validDate(value: []const u8) bool { + if (value.len != "YYYY-MM-DD".len or value[4] != '-' or value[7] != '-') return false; + for (value, 0..) |byte, index| { + if (index == 4 or index == 7) continue; + if (!std.ascii.isDigit(byte)) return false; + } + const month = std.fmt.parseInt(u8, value[5..7], 10) catch return false; + const day = std.fmt.parseInt(u8, value[8..10], 10) catch return false; + return month >= 1 and month <= 12 and day >= 1 and day <= 31; +} + fn writeToolChoice(json: *std.json.Stringify, choice: model_types.ToolChoice) !void { try json.objectField("tool_choice"); switch (choice) { @@ -1393,7 +1606,14 @@ test "OpenAI encodes detailed message forms and rejects lossy forms" { .id = "gateway-file", .provider_name = "gateway", } } }} } }}; - const gateway_body = try encodeRequestForProvider(std.testing.allocator, "gateway", "gpt-test", .{ .messages = &gateway_messages }); + const gateway_body = try encodeRequestForProvider( + std.testing.allocator, + "gateway", + "gpt-test", + .{ .messages = &gateway_messages }, + .openai, + .openai, + ); defer std.testing.allocator.free(gateway_body); try std.testing.expect(std.mem.indexOf(u8, gateway_body, "gateway-file") != null); diff --git a/src/providers/profiles.zig b/src/providers/profiles.zig index dc89000..f807426 100644 --- a/src/providers/profiles.zig +++ b/src/providers/profiles.zig @@ -227,6 +227,38 @@ pub fn azureOpenAI(model_name: []const u8) ?model.ModelProfile { return null; } +/// xAI language models served through the native Responses API. +pub fn xAIResponses(model_name: []const u8) ?model.ModelProfile { + if (!startsWith(model_name, "grok") and !std.ascii.eqlIgnoreCase(model_name, "latest")) return null; + var profile = familyProfile(.grok, model_name); + profile.supports_json_schema_output = true; + profile.supports_json_object_output = true; + profile.supports_thinking = true; + profile.supports_truncation = true; + profile.extra_body_kind = .xai; + profile.reasoning_efforts = reasoningEfforts(); + if (std.mem.indexOf(u8, model_name, "multi-agent") != null) profile.reasoning_efforts.insert(.xhigh); + profile.builtin_tools = model.ModelProfile.BuiltinToolSet.initMany(&.{ + .web_search, + .x_search, + .code_execution, + .file_search, + .remote_mcp, + }); + profile.content_types = model.ModelProfile.ContentTypeSet.initMany(&.{.image}); + return profile; +} + +/// xAI language models served through OpenAI-compatible Chat Completions. +pub fn xAIChat(model_name: []const u8) ?model.ModelProfile { + var profile = xAIResponses(model_name) orelse return null; + profile.supports_truncation = false; + profile.supports_thinking = false; + profile.extra_body_kind = .openai_compatible; + profile.builtin_tools = model.ModelProfile.BuiltinToolSet.initEmpty(); + return profile; +} + /// OpenAI model IDs exposed by Amazon Bedrock Mantle. pub fn bedrock(model_name: []const u8) ?model.ModelProfile { if (!startsWith(model_name, "openai.")) return null; @@ -397,6 +429,12 @@ test "named compatible providers resolve known families and reject unknown ones" try std.testing.expect(!azureOpenAI("grok-4").?.supports_seed); try std.testing.expect(azureOpenAI("meta-llama").?.supports_tools); try std.testing.expect(azureOpenAI("unknown") == null); + try std.testing.expect(xAIResponses("grok-4.6").?.supportsBuiltinTool(.x_search)); + try std.testing.expect(xAIResponses("grok-4.20-multi-agent").?.supportsReasoningEffort(.xhigh)); + try std.testing.expectEqual(model.ExtraBodyKind.xai, xAIResponses("latest").?.extra_body_kind.?); + try std.testing.expectEqual(model.ExtraBodyKind.openai_compatible, xAIChat("grok-4.6").?.extra_body_kind.?); + try std.testing.expect(!xAIChat("grok-4.6").?.supportsBuiltinTool(.remote_mcp)); + try std.testing.expect(xAIResponses("not-grok") == null); try std.testing.expect(bedrock("openai.gpt-oss-20b").?.supportsReasoningEffort(.medium)); try std.testing.expect(bedrock("openai.gpt-5.4").?.supportsReasoningEffort(.high)); diff --git a/src/providers/xai.zig b/src/providers/xai.zig new file mode 100644 index 0000000..d816ebf --- /dev/null +++ b/src/providers/xai.zig @@ -0,0 +1,215 @@ +//! xAI provider with native Responses and explicit Chat Completions clients. +//! +//! The native client shares the Responses envelope with OpenAI while keeping +//! xAI's tool vocabulary and extension body isolated behind its own dialect. + +const std = @import("std"); +const model_types = @import("../model.zig"); +const provider_types = @import("../provider.zig"); +const transport = @import("../transport.zig"); +const compatible = @import("openai_compatible.zig"); +const openai = @import("openai.zig"); +const provider_profiles = @import("profiles.zig"); + +pub const api_base = "https://api.x.ai/v1"; +pub const api_key_env = "XAI_API_KEY"; +pub const Error = openai.Error; + +const responses_defaults: compatible.ClientDefaults = .{ + .base_url = api_base, + .provider_name = "xai", + .profile = .{}, + .model_profile_lookup = provider_profiles.xAIResponses, +}; + +const chat_defaults: compatible.ClientDefaults = .{ + .base_url = api_base, + .provider_name = "xai", + .profile = provider_profiles.openai_compatible.unknown, + .model_profile_lookup = provider_profiles.xAIChat, +}; + +/// Provider state for xAI's native Responses API. +pub const Provider = compatible.ProviderWithDefaults(responses_defaults); +/// Provider state for xAI's OpenAI-compatible Chat Completions API. +pub const ChatProvider = compatible.ProviderWithDefaults(chat_defaults); +/// Explicit compatibility client. Prefer `Client` for xAI-managed tools. +pub const ChatClient = compatible.ClientWithDefaults(chat_defaults); + +/// Native xAI Responses client. +pub const Client = struct { + model_name: []const u8, + provider: provider_types.Provider, + settings: model_types.ModelSettings = .{}, + profile: model_types.ModelProfile = .{}, + + pub fn model(self: *Client) model_types.Model { + var resolved_profile = self.provider.modelProfile(self.model_name, self.profile); + resolved_profile.extra_body_kind = .xai; + return .{ + .context = self, + .profile = resolved_profile, + .provider_name = self.provider.name, + .model_name = self.model_name, + .settings = self.settings, + .requestFn = request, + .streamFn = stream, + }; + } + + fn request( + context: *anyopaque, + allocator: std.mem.Allocator, + value: model_types.ModelRequest, + ) !model_types.ModelResponse { + const self: *Client = @ptrCast(@alignCast(context)); + var client = openai.Client{ + .model_name = self.model_name, + .provider = self.provider, + .dialect = .xai, + .extra_body_kind = .xai, + }; + return client.model().request(allocator, value); + } + + fn stream( + context: *anyopaque, + allocator: std.mem.Allocator, + value: model_types.ModelRequest, + sink: model_types.ModelStreamSink, + ) !model_types.ModelResponse { + const self: *Client = @ptrCast(@alignCast(context)); + var client = openai.Client{ + .model_name = self.model_name, + .provider = self.provider, + .dialect = .xai, + .extra_body_kind = .xai, + }; + return client.model().stream(allocator, value, sink); + } +}; + +test "native client owns the xAI Responses dialect" { + const State = struct { + fn send(_: *anyopaque, allocator: std.mem.Allocator, request: transport.Request) !transport.Response { + try std.testing.expectEqual(transport.Method.POST, request.method); + try std.testing.expectEqualStrings("https://api.x.ai/v1/responses", request.url); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"type\":\"x_search\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"type\":\"code_interpreter\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"allowed_domains\":[\"x.ai\"]") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"enable_image_search\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"allowed_x_handles\":[\"xai\"]") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"from_date\":\"2026-01-01\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"enable_video_understanding\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"vector_store_ids\":[\"collection_1\"]") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"max_num_results\":10") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"server_label\":\"docs\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"server_description\":\"Documentation\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"allowed_tools\":[\"search\"]") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"authorization\":\"test-token\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"x-scope\":\"read\"") != null); + try std.testing.expect(std.mem.indexOf(u8, request.body, "\"xai_extension\":true") != null); + return .{ + .status = 200, + .body = try allocator.dupe(u8, "{\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"pong\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}"), + }; + } + }; + var marker: u8 = 0; + var provider = Provider.init("secret", .{ .context = &marker, .sendFn = State.send }); + var client = Client{ .model_name = "grok-4.6", .provider = provider.provider() }; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const response = try client.model().request(arena.allocator(), .{ + .messages = &.{.{ .request = .{ .parts = &.{.{ .user_prompt = .{ .text = "ping" } }} } }}, + .builtin_tools = &.{ + .{ .web_search = .{ + .allowed_domains = &.{"x.ai"}, + .enable_image_understanding = false, + .enable_image_search = true, + } }, + .{ .x_search = .{ + .allowed_x_handles = &.{"xai"}, + .from_date = "2026-01-01", + .to_date = "2026-08-15", + .enable_image_understanding = true, + .enable_video_understanding = true, + } }, + .{ .code_execution = .{} }, + .{ .file_search = .{ .vector_store_ids = &.{"collection_1"}, .max_num_results = 10 } }, + .{ .remote_mcp = .{ + .server_url = "https://mcp.example.test", + .server_label = "docs", + .server_description = "Documentation", + .allowed_tools = &.{"search"}, + .authorization = "test-token", + .headers = &.{.{ .name = "x-scope", .value = "read", .sensitive = true }}, + } }, + }, + .settings = .{ .extra_body = .{ .xai = "{\"xai_extension\":true}" } }, + }); + try std.testing.expectEqualStrings("pong", response.parts[0].text); + try std.testing.expect(client.model().profile.supportsBuiltinTool(.remote_mcp)); + try std.testing.expectEqual(model_types.ExtraBodyKind.xai, client.model().profile.extra_body_kind.?); +} + +test "native xAI tools reject malformed provider configuration before transport" { + const State = struct { + fn send(_: *anyopaque, _: std.mem.Allocator, _: transport.Request) !transport.Response { + return error.UnexpectedRequest; + } + }; + var marker: u8 = 0; + var provider = Provider.init("secret", .{ .context = &marker, .sendFn = State.send }); + var client = Client{ .model_name = "grok-4.6", .provider = provider.provider() }; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const invalid = [_]model_types.BuiltinTool{ + .{ .web_search = .{ .allowed_domains = &.{"x.ai"}, .excluded_domains = &.{"example.com"} } }, + .{ .web_search = .{ .allowed_domains = &.{ "1", "2", "3", "4", "5", "6" } } }, + .{ .web_search = .{ .allowed_domains = &.{""} } }, + .{ .x_search = .{ .allowed_x_handles = &.{"xai"}, .excluded_x_handles = &.{"grok"} } }, + .{ .x_search = .{ .allowed_x_handles = &.{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21" } } }, + .{ .x_search = .{ .from_date = "2026-1-01" } }, + .{ .x_search = .{ .from_date = "2026-13-01" } }, + .{ .x_search = .{ .from_date = "2026-01-32" } }, + .{ .x_search = .{ .from_date = "2026-0a-01" } }, + .{ .x_search = .{ .from_date = "2026-08-15", .to_date = "2026-01-01" } }, + .{ .file_search = .{ .vector_store_ids = &.{} } }, + .{ .file_search = .{ .vector_store_ids = &.{"collection"}, .max_num_results = 0 } }, + .{ .remote_mcp = .{ .server_url = "http://mcp.example.test", .server_label = "docs" } }, + .{ .remote_mcp = .{ .server_url = "https://mcp.example.test", .server_label = "" } }, + .{ .remote_mcp = .{ .server_url = "https://mcp.example.test", .server_label = "docs", .allowed_tools = &.{""} } }, + .{ .remote_mcp = .{ .server_url = "https://mcp.example.test", .server_label = "docs", .authorization = "" } }, + .{ .remote_mcp = .{ .server_url = "https://mcp.example.test", .server_label = "docs", .headers = &.{.{ .name = "", .value = "x" }} } }, + .{ .remote_mcp = .{ .server_url = "https://mcp.example.test", .server_label = "docs", .headers = &.{.{ .name = "x", .value = "" }} } }, + .{ .remote_mcp = .{ .server_url = "https://mcp.example.test", .server_label = "docs", .headers = &.{.{ .name = "x\nname", .value = "x" }} } }, + .{ .remote_mcp = .{ .server_url = "https://mcp.example.test", .server_label = "docs", .headers = &.{.{ .name = "x", .value = "bad\rvalue" }} } }, + .{ .remote_mcp = .{ .server_url = "https://mcp.example.test", .server_label = "docs", .headers = &.{ .{ .name = "X-Test", .value = "one" }, .{ .name = "x-test", .value = "two" } } } }, + }; + for (invalid) |tool| try std.testing.expectError( + error.InvalidRequestEncoding, + client.model().request(arena.allocator(), .{ .messages = &.{}, .builtin_tools = &.{tool} }), + ); +} + +test "Chat compatibility is explicit and has a separate profile" { + const State = struct { + fn send(_: *anyopaque, allocator: std.mem.Allocator, request: transport.Request) !transport.Response { + try std.testing.expectEqualStrings("https://api.x.ai/v1/chat/completions", request.url); + return .{ + .status = 200, + .body = try allocator.dupe(u8, "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}"), + }; + } + }; + var marker: u8 = 0; + var provider = ChatProvider.init("secret", .{ .context = &marker, .sendFn = State.send }); + var client = ChatClient{ .model_name = "grok-4.6", .provider = provider.provider() }; + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const response = try client.model().request(arena.allocator(), .{ .messages = &.{} }); + try std.testing.expectEqualStrings("pong", response.parts[0].text); + try std.testing.expect(!client.model().profile.supportsBuiltinTool(.x_search)); +} diff --git a/src/settings.zig b/src/settings.zig index 9b2c635..4a30cf6 100644 --- a/src/settings.zig +++ b/src/settings.zig @@ -54,6 +54,7 @@ pub const RequestHeader = struct { /// Provider adapter expected to consume an extension body. pub const ExtraBodyKind = enum { openai, + xai, openai_compatible, anthropic, bedrock, @@ -65,6 +66,7 @@ pub const ExtraBodyKind = enum { /// adapter after model selection or fallback. pub const ProviderExtraBody = union(ExtraBodyKind) { openai: []const u8, + xai: []const u8, openai_compatible: []const u8, anthropic: []const u8, bedrock: []const u8, diff --git a/tests/consumers/providers/src/main.zig b/tests/consumers/providers/src/main.zig index 96bfef1..a36c9c5 100644 --- a/tests/consumers/providers/src/main.zig +++ b/tests/consumers/providers/src/main.zig @@ -22,6 +22,10 @@ pub fn main() !void { _ = zigai.providers.azure_openai.Client; _ = zigai.providers.azure_openai.ChatClient; _ = zigai.providers.azure_openai.ResponsesClient; + _ = zigai.providers.xai.Provider; + _ = zigai.providers.xai.Client; + _ = zigai.providers.xai.ChatProvider; + _ = zigai.providers.xai.ChatClient; _ = zigai.AgentError; _ = zigai.RequestMessage; _ = zigai.ResponseMessage;