From 9ca01149818e360c9b1721fe0891a666cbbe68cf Mon Sep 17 00:00:00 2001 From: wiseaidev Date: Tue, 5 May 2026 15:05:32 +0300 Subject: [PATCH] fix: drop deprecated models && use latest && impl cli & tui. Signed-off-by: wiseaidev --- .github/workflows/ci.yml | 12 +- Cargo.toml | 32 ++- LICENSE | 2 +- README.md | 366 +++++++++++---------------- src/api_key.rs | 13 + src/chat_compl.rs | 280 +++++++++++++++++++-- src/cli.rs | 173 +++++++++++++ src/client.rs | 15 +- src/completions.rs | 15 +- src/deferred_compl.rs | 78 ++++++ src/embedding.rs | 15 +- src/embedding_get.rs | 15 +- src/embedding_mod.rs | 23 +- src/error.rs | 13 + src/get_mod.rs | 15 +- src/lang_mod.rs | 15 +- src/lib.rs | 22 +- src/list_lang_mod.rs | 15 +- src/list_mod.rs | 15 +- src/main.rs | 147 +++++++++++ src/responses.rs | 409 ++++++++++++++++++++++++++++++ src/traits.rs | 30 +++ src/tui.rs | 523 +++++++++++++++++++++++++++++++++++++++ tests/api_key.rs | 20 +- tests/chat_compl.rs | 32 ++- tests/completions.rs | 25 +- tests/embedding.rs | 20 +- tests/embedding_get.rs | 14 ++ tests/embedding_list.rs | 16 +- tests/get_mod.rs | 18 +- tests/lang_mod.rs | 18 +- tests/list_lang_mod.rs | 16 +- tests/list_mod.rs | 16 +- 33 files changed, 2142 insertions(+), 296 deletions(-) create mode 100644 src/cli.rs create mode 100644 src/deferred_compl.rs create mode 100644 src/main.rs create mode 100644 src/responses.rs create mode 100644 src/tui.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dbff26..6a93283 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,20 +2,18 @@ name: CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] env: CARGO_TERM_COLOR: always jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Build - run: cargo build --verbose - + - uses: actions/checkout@v4 + - name: Build + run: cargo build --all-features --verbose diff --git a/Cargo.toml b/Cargo.toml index 923602f..6061573 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,25 +1,41 @@ [package] name = "x-ai" version = "0.0.1" -edition = "2021" -rust-version = "1.75" +edition = "2024" +rust-version = "1.89" description = "✨ A cli, tui, and sdk for interacting with the 𝕏-AI API" license = "MIT" keywords = ["client", "xai", "grok", "ai"] categories = ["command-line-utilities", "science"] -repository = "https://github.com/opensass/x-ai" +repository = "https://github.com/wiseaidotdev/x-ai" documentation = "https://docs.rs/x-ai" authors = ["Mahmoud Harmouch "] exclude = ["tests"] +[[bin]] +name = "xai" +path = "src/main.rs" +required-features = ["cli"] + [dependencies] -reqwest = { version = "0.12.9", features = ["json", "blocking"] } -serde = { version = "1.0.215", features = ["derive"] } -serde_json = "1.0.133" -thiserror = "2.0.3" +reqwest = { version = "0.13.3", features = ["json", "blocking"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +thiserror = "2.0.18" +tokio = { version = "1.52.2", features = ["full"], optional = true } +anyhow = { version = "1.0.102", optional = true } +clap = { version = "4.6.1", features = ["derive"], optional = true } +ratatui = { version = "0.29", features = ["crossterm"], optional = true } +tui-input = { version = "0.11", optional = true } +crossterm = { version = "0.28", optional = true } +strum = { version = "0.27", features = ["derive"], optional = true } +strum_macros = { version = "0.27", optional = true } + +[features] +cli = ["clap", "ratatui", "tui-input", "crossterm", "strum", "strum_macros", "anyhow", "tokio"] [dev-dependencies] -tokio = { version = "1.41.1", features = ["full"] } +tokio = { version = "1.52.2", features = ["full"] } [profile.release] opt-level = "z" diff --git a/LICENSE b/LICENSE index 062d4e1..48fce59 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Open SASS Core Maintainers +Copyright (c) 2026 Wise AI Core Maintainers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index bd16b8b..7383795 100644 --- a/README.md +++ b/README.md @@ -3,62 +3,130 @@ # ✨ 𝕏-AI [![CircleCI](https://dl.circleci.com/status-badge/img/circleci/2rCDyKJRUEePhb1wtzWHqR/wCPbiPVjGAET17HXk9U2d/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/circleci/2rCDyKJRUEePhb1wtzWHqR/wCPbiPVjGAET17HXk9U2d/tree/main) -![Version](https://img.shields.io/crates/v/x_ai) -![Downloads](https://img.shields.io/crates/d/x_ai) -![License](https://img.shields.io/crates/l/x_ai) -![Last Commit](https://img.shields.io/github/last-commit/opensass/x-ai) -![Docs](https://img.shields.io/docsrs/x-ai) +[![Crates.io](https://img.shields.io/crates/v/x-ai.svg)](https://crates.io/crates/x-ai) +[![docs](https://docs.rs/x-ai/badge.svg)](https://docs.rs/x-ai/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![banner](https://github.com/user-attachments/assets/7b420bc7-fcfe-4aa6-b79a-6dfe1a2c019b) -> **𝕏-AI** is a **complete SDK** and a WIP **CLI/TUI** that provides a powerful and intuitive interface to interact with the [**X-AI API**](https://docs.x.ai/api/). +> ✨ X-AI: A CLI, TUI, and SDK for interacting with the [xAI Grok API](https://docs.x.ai/api/), allowing you to chat with Grok models, create embeddings, and inspect your API key and available models. ---- +## 📖 Table of Contents -## 📜 Table of Contents +- [Installation](#-installation) +- [Features](#-features) +- [Usage as CLI](#-usage-as-cli) +- [Options](#-options) +- [Subcommands](#-subcommands) +- [Usage as SDK](#-usage-as-sdk) +- [Contributing](#-contributing) +- [License](#-license) -1. [Features](#-features) -2. [Installation](#-installation) -3. [Usage Examples](#-usage-examples) - - [Fetch API Key Information 🔑](#fetch-api-key-information-) - - [Chat Completions 💬](#chat-completions-) - - [Text Completions 📝](#text-completions-) - - [Embedding Creation 📊](#embedding-creation-) - - [List Models 📜](#list-models-) -4. [Examples](#-examples) -5. [Contributing](#-contributing) -6. [License](#-license) +## 🚀 Installation ---- +To install the `xai` CLI: + +```bash +cargo install x-ai --all-features +``` ## ✨ Features -- Fetch API Key Information 🔑 -- Chat Completions 💬 -- Text Completions 📝 -- Embedding Creation 📊 -- Fetch Model Information 🧐 -- List Embedding Models 📜 -- Fetch Language Model Details 🌐 -- List Language Models 🗃️ +- Interactive TUI with Settings, Chat, History, and Model Info tabs +- Chat with Grok models (`grok-4` by default) +- Legacy text completions +- Create text embeddings +- List all available models +- Inspect a specific model's details +- Fetch API key information +- Deferred Chat Completions +- Full Responses API (Create, Get, Delete) + +## Environment Variables ---- +Before using the CLI or SDK, export your API key: -## 📦 Installation +```bash +export XAI_API_KEY= +export XAI_MODEL=grok-4 # optional, defaults to grok-4 +``` -Add the following to your `Cargo.toml`: +Generate an API key from the [xAI Console](https://console.x.ai). -```toml -[dependencies] -x_ai = "0.0.1" -tokio = { version = "1.41.1", features = ["full"] } +## ⌨️ Usage as CLI + +### Launch TUI (default, no subcommand): + +```sh +xai +``` + +### Chat with Grok: + +```sh +xai chat -t "What is the answer to life?" +``` + +### Legacy text completion: + +```sh +xai complete -p "Once upon a time" --max-tokens 200 +``` + +### Create embeddings: + +```sh +xai embed -t "Hello, world!" +``` + +### List all models: + +```sh +xai models +``` + +### Get details for a specific model: + +```sh +xai model -m grok-4 ``` ---- +### Show API key info: -## 🛠️ Usage Examples +```sh +xai apikey +``` + +## 🎨 Options + +| Option | Description | +| ----------- | ---------------------------------------------- | +| _(none)_ | Launch TUI mode. | +| `--api-key` | xAI API key (overrides `XAI_API_KEY` env var). | +| `--model` | Model to use (overrides `XAI_MODEL` env var). | + +## 🛠 Subcommands + +| Subcommand | Description | +| ---------- | --------------------------------- | +| `chat` | Chat with a Grok model. | +| `complete` | Legacy text completion. | +| `embed` | Create text embeddings. | +| `models` | List all available models. | +| `model` | Get details for a specific model. | +| `apikey` | Show API key information. | + +## ✨ Usage as SDK + +Add to your `Cargo.toml`: + +```toml +[dependencies] +x-ai = "0.0.1" +tokio = { version = "1", features = ["full"] } +``` ### Fetch API Key Information 🔑 @@ -66,8 +134,7 @@ tokio = { version = "1.41.1", features = ["full"] } use std::env; use x_ai::api_key::ApiKeyRequestBuilder; use x_ai::client::XaiClient; -use x_ai::traits::ApiKeyFetcher; -use x_ai::traits::ClientConfig; +use x_ai::traits::{ApiKeyFetcher, ClientConfig}; #[tokio::main] async fn main() { @@ -81,32 +148,19 @@ async fn main() { .to_string(), ); - let request_builder = ApiKeyRequestBuilder::new(client); - - let result = request_builder.fetch_api_key_info().await; - - match result { - Ok(api_key_info) => println!("API Key ID: {}", api_key_info.api_key_id), - Err(err) => eprintln!("Error fetching API key info: {:?}", err), - } + let builder = ApiKeyRequestBuilder::new(client.clone()); + let result = builder.fetch_api_key_info().await; + println!("{:?}", result.unwrap()); } - -// Output - -// API Key ID: 06e3dd6...5e7f61 ``` ---- - ### Chat Completions 💬 -```rust +```rust,ignore use std::env; -use x_ai::chat_compl::ChatCompletionsRequestBuilder; +use x_ai::chat_compl::{ChatCompletionsRequestBuilder, Message}; use x_ai::client::XaiClient; -use x_ai::traits::ChatCompletionsFetcher; -use x_ai::chat_compl::Message; -use x_ai::traits::ClientConfig; +use x_ai::traits::{ChatCompletionsFetcher, ClientConfig}; #[tokio::main] async fn main() { @@ -121,46 +175,20 @@ async fn main() { ); let messages = vec![ - Message { - role: "system".to_string(), - content: "You are Grok, a chatbot inspired by the Hitchhiker's Guide to the Galaxy." - .to_string(), - }, - Message { - role: "user".to_string(), - content: "What is the answer to life and the universe?".to_string(), - }, + Message::text("system", "You are Grok, a chatbot inspired by the Hitchhiker's Guide to the Galaxy."), + Message::text("user", "What is the answer to life and the universe?"), ]; - let request_builder = - ChatCompletionsRequestBuilder::new(client.clone(), "grok-beta".to_string(), messages) - .temperature(0.0) - .stream(false); - - let request = request_builder - .clone() - .build() - .expect("Failed to build request"); - - let response = request_builder.create_chat_completion(request).await; - match response { - Ok(completion) => { - println!("Chatbot Response: {}", completion.choices[0].message.content); - } - Err(err) => eprintln!("Error: {:?}", err), - } + let builder = ChatCompletionsRequestBuilder::new(client.clone(), "grok-4".to_string(), messages); + let request = builder.clone().build().unwrap(); + let completion = builder.create_chat_completion(request).await.unwrap(); + println!("Response: {}", completion.choices[0].message.content); } - -// Output - -// Chatbot Response: The answer to life, the universe, and everything is **42**. However, this answer is famously incomplete without knowing the question, which remains unknown. This concept comes from Douglas Adams' "The Hitchhiker's Guide to the Galaxy." If you're looking for deeper meaning or a more personal answer, I'd say it's about finding what gives your life purpose and joy, which can be quite different for everyone. What do you think might be your personal answer to life and the universe? ``` ---- - ### Text Completions 📝 -```rust +```rust,ignore use std::env; use x_ai::client::XaiClient; use x_ai::completions::CompletionsRequestBuilder; @@ -178,48 +206,26 @@ async fn main() { .to_string(), ); - let request_builder = CompletionsRequestBuilder::new( + let builder = CompletionsRequestBuilder::new( client.clone(), - "grok-beta".to_string(), - "Write a short poem about Rust programming.".to_string(), + "grok-4".to_string(), + "What is AI?".to_string(), ) - .temperature(0.7) .max_tokens(50); - let request = request_builder.clone() - .build() - .expect("Failed to build request"); - - let response = request_builder.create_completions(request).await; - - match response { - Ok(completion) => println!("Generated Text: {}", completion.choices[0].text), - Err(err) => eprintln!("Error: {:?}", err), - } + let request = builder.clone().build().unwrap(); + let completion = builder.create_completions(request).await.unwrap(); + println!("{}", completion.choices[0].text); } - -// Output - -// Generated Text: Make the poem rhyme. - -// In the land of code, a language so bright, -// Rust emerges with all its might. -// With safety and speed, it's quite the sight, -// Guarding memory with all its might. - -// Fear not the bugs, nor the seg ``` ---- - ### Embedding Creation 📊 -```rust +```rust,ignore use std::env; use x_ai::client::XaiClient; use x_ai::embedding::EmbeddingRequestBuilder; -use x_ai::traits::ClientConfig; -use x_ai::traits::EmbeddingFetcher; +use x_ai::traits::{ClientConfig, EmbeddingFetcher}; #[tokio::main] async fn main() { @@ -233,41 +239,26 @@ async fn main() { .to_string(), ); - let input_texts = vec!["Hello, world!".to_string(), "Rust is awesome!".to_string()]; - let model = "text-embedding-3-small".to_string(); - let encoding_format = "float32".to_string(); - - let request_builder = - EmbeddingRequestBuilder::new(client.clone(), model, input_texts, encoding_format); - - let request = request_builder - .clone() - .build() - .expect("Failed to build request"); - - let response = request_builder.create_embedding(request).await; + let builder = EmbeddingRequestBuilder::new( + client.clone(), + "grok-4".to_string(), + vec!["Hello, world!".to_string()], + "float".to_string(), + ); - match response { - Ok(embedding) => println!("Embedding Data: {:?}", embedding.data), - Err(err) => eprintln!("Error: {:?}", err), - } + let request = builder.clone().build().unwrap(); + let embedding = builder.create_embedding(request).await.unwrap(); + println!("{:?}", embedding.data); } - -// Output - -// TODO ``` ---- - ### List Models 📜 -```rust +```rust,ignore use std::env; use x_ai::client::XaiClient; use x_ai::list_mod::ReducedModelListRequestBuilder; -use x_ai::traits::ClientConfig; -use x_ai::traits::ListModelFetcher; +use x_ai::traits::{ClientConfig, ListModelFetcher}; #[tokio::main] async fn main() { @@ -281,85 +272,20 @@ async fn main() { .to_string(), ); - let request_builder = ReducedModelListRequestBuilder::new(client); - - let result = request_builder.fetch_model_info().await; - - match result { - Ok(model_list) => { - for model in model_list.data { - println!("Model ID: {}, Owned By: {}", model.id, model.owned_by); - } - } - Err(err) => eprintln!("Error fetching models: {:?}", err), + let builder = ReducedModelListRequestBuilder::new(client.clone()); + let models = builder.fetch_model_info().await.unwrap(); + for model in models.data { + println!("• {} (owned by: {})", model.id, model.owned_by); } } - -// Output - -// Model ID: grok-beta, Owned By: xai -// Model ID: grok-vision-beta, Owned By: xai ``` ---- - -## 📌 Examples - -> [!WARNING] -> 🚧 Under Construction - -This repository contains a list of notebooks examples on how to use the sdk and or the cli. To use the notebooks in this repository, you need to set up your environment. Follow these steps to get started: - -1. Clone the repository to your local machine: - - ```sh - git clone https://github.com/opensass/x-ai.git - ``` - -1. Install the required dependencies and libraries. Make sure you have [`Rust`](https://rustup.rs/), [`Jupyter Notebook`](https://jupyter.org/install), and [`evcxr_jupyter`](https://github.com/evcxr/evcxr/blob/main/evcxr_jupyter/README.md) installed on your system. - - ```sh - # Install a Rust toolchain (e.g. nightly): - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain nightly - - # Although not recommended, but, You may need to clean the python thing cz of new v3.12 venv breaking changes - sudo rm /usr/lib/python3.*/EXTERNALLY-MANAGED - - # Install Jupyter Notebook - pip install notebook - - # Install evcxr_jupyter, upgrade soon - cargo install evcxr_jupyter - evcxr_jupyter --install - ``` - -1. Navigate to the cloned repository and build the project: - - ```sh - cd x-ai - cargo build --release --all-features - ``` - -1. Start Jupyter Notebook: - - ```sh - jupyter notebook - ``` - -1. Access the notebooks in your web browser by clicking on the notebook file you want to explore. - -| ID | Example | Open on GitHub | Launch on Binder | Launch on Colab | -|----|---------------|-----------|:-------------|-------------| -| 1 | **Basic** | [![Github](https://img.shields.io/badge/launch-Github-181717.svg?logo=github&logoColor=white)](./examples/basic.ipynb) | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/opensass/x-ai/main?filepath=examples/basic.ipynb) | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/opensass/x-ai/blob/main/examples/basic.ipynb) | - ---- - ## 🤝 Contributing -Contributions and feedback are welcome! If you'd like to contribute, report an issue, or suggest an enhancement, please engage with the project on [GitHub](https://github.com/opensass/x-ai). Your contributions help improve this crate for the community. +Contributions and feedback are welcome! If you'd like to contribute, report an issue, or suggest an enhancement, please engage with the project on [GitHub](https://github.com/wiseaidotdev/x-ai). Your contributions help improve this crate for the community. ---- +## 📄 License -## 📜 License +This project is licensed under the [MIT License](LICENSE). -This crate is licensed under the MIT License. See the [`LICENSE`](LICENSE) file for details. +© 2026 Wise AI Foundation diff --git a/src/api_key.rs b/src/api_key.rs index 88aa631..31f60f1 100644 --- a/src/api_key.rs +++ b/src/api_key.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: [docs/api-reference#api-key](https://docs.x.ai/docs/api-reference#api-key) use crate::error::check_for_model_error; @@ -60,3 +67,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/chat_compl.rs b/src/chat_compl.rs index 22d268d..13ab491 100644 --- a/src/chat_compl.rs +++ b/src/chat_compl.rs @@ -1,11 +1,120 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: [docs/api-reference#chat-completions](https://docs.x.ai/docs/api-reference#chat-completions) -use crate::error::check_for_model_error; use crate::error::XaiError; +use crate::error::check_for_model_error; use crate::traits::ChatCompletionsFetcher; use crate::traits::ClientConfig; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; + +/// A single part of a multi-modal message content array. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentPart { + Text { text: String }, + ImageUrl { image_url: ImageUrl }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ImageUrl { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// Message content, either a plain string or a list of content parts (text/image). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MessageContent { + Text(String), + Parts(Vec), +} + +impl fmt::Display for ContentPart { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ContentPart::Text { text } => write!(f, "{}", text), + ContentPart::ImageUrl { image_url } => write!(f, "[Image: {}]", image_url.url), + } + } +} + +impl fmt::Display for MessageContent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MessageContent::Text(text) => write!(f, "{}", text), + MessageContent::Parts(parts) => { + for (i, part) in parts.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + write!(f, "{}", part)?; + } + Ok(()) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Message { + pub role: String, + pub content: MessageContent, + /// Reasoning trace returned by the model (assistant messages only). + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_content: Option, + /// Refusal reason returned by the model when it cannot respond. + #[serde(skip_serializing_if = "Option::is_none")] + pub refusal: Option, + /// Tool calls requested by the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, +} + +impl Message { + /// Convenience constructor for a plain-text message. + pub fn text(role: impl Into, content: impl Into) -> Self { + Self { + role: role.into(), + content: MessageContent::Text(content.into()), + reasoning_content: None, + refusal: None, + tool_calls: None, + } + } +} + +/// Parameters controlling live-search behaviour. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchParameters { + #[serde(skip_serializing_if = "Option::is_none")] + pub from_date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to_date: Option, + /// `"off"` | `"on"` | `"auto"` + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub return_citations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_search_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sources: Option>, +} + +/// Options for streaming responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamOptions { + pub include_usage: bool, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatCompletionRequest { @@ -14,8 +123,12 @@ pub struct ChatCompletionRequest { pub stream: bool, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, + /// Deprecated, use `max_completion_tokens` instead. #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, + /// Preferred replacement for `max_tokens`. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_completion_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] pub frequency_penalty: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -36,32 +149,64 @@ pub struct ChatCompletionRequest { pub user: Option, #[serde(skip_serializing_if = "Option::is_none")] pub logit_bias: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Message { - pub role: String, - pub content: String, + /// Return a `request_id` for deferred retrieval instead of blocking. + #[serde(skip_serializing_if = "Option::is_none")] + pub deferred: Option, + /// Allow the model to execute multiple tool calls in parallel. + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, + /// Reasoning effort for reasoning models: `"low"` | `"high"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + /// JSON schema or format object for structured output. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_format: Option, + /// Live-search configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub search_parameters: Option, + /// Streaming options (only relevant when `stream: true`). + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, + /// Controls which tool (if any) the model should call. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + /// List of tools the model may call (functions). + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + /// OpenAI-compat web search options (mapped internally by xAI). + #[serde(skip_serializing_if = "Option::is_none")] + pub web_search_options: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChatCompletionResponse { - pub id: String, - pub object: String, - pub created: u64, - pub model: String, - pub choices: Vec, +pub struct Logprobs { #[serde(skip_serializing_if = "Option::is_none")] - pub usage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub system_fingerprint: Option, + pub content: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Choice { pub index: u32, pub message: Message, - pub finish_reason: String, + pub finish_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logprobs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptTokensDetails { + pub text_tokens: u32, + pub audio_tokens: u32, + pub image_tokens: u32, + pub cached_tokens: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompletionTokensDetails { + pub reasoning_tokens: u32, + pub audio_tokens: u32, + pub accepted_prediction_tokens: u32, + pub rejected_prediction_tokens: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -69,6 +214,38 @@ pub struct Usage { pub prompt_tokens: u32, pub completion_tokens: u32, pub total_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_tokens_details: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completion_tokens_details: Option, + /// Cost in USD ticks (10 billion ticks = $1). + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_in_usd_ticks: Option, + /// Number of live-search sources consumed. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_sources_used: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatCompletionResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_fingerprint: Option, + /// Citations from live-search sources. + #[serde(skip_serializing_if = "Option::is_none")] + pub citations: Option>, + /// Files generated during the response (e.g. code execution output). + #[serde(skip_serializing_if = "Option::is_none")] + pub output_files: Option>, + /// Returned instead of the normal response when `deferred: true`. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, } #[derive(Debug, Clone)] @@ -89,6 +266,7 @@ where messages, temperature: None, max_tokens: None, + max_completion_tokens: None, frequency_penalty: None, presence_penalty: None, n: None, @@ -100,6 +278,15 @@ where seed: None, user: None, logit_bias: None, + deferred: None, + parallel_tool_calls: None, + reasoning_effort: None, + response_format: None, + search_parameters: None, + stream_options: None, + tool_choice: None, + tools: None, + web_search_options: None, }, } } @@ -109,11 +296,17 @@ where self } + /// Deprecated. Prefer `max_completion_tokens`. pub fn max_tokens(mut self, max_tokens: u32) -> Self { self.request.max_tokens = Some(max_tokens); self } + pub fn max_completion_tokens(mut self, max_completion_tokens: u32) -> Self { + self.request.max_completion_tokens = Some(max_completion_tokens); + self + } + pub fn frequency_penalty(mut self, frequency_penalty: f32) -> Self { self.request.frequency_penalty = Some(frequency_penalty); self @@ -169,6 +362,53 @@ where self } + pub fn deferred(mut self, deferred: bool) -> Self { + self.request.deferred = Some(deferred); + self + } + + pub fn parallel_tool_calls(mut self, parallel_tool_calls: bool) -> Self { + self.request.parallel_tool_calls = Some(parallel_tool_calls); + self + } + + /// Reasoning effort for reasoning models: `"low"` or `"high"`. + /// Not supported by `grok-4` models. + pub fn reasoning_effort(mut self, effort: impl Into) -> Self { + self.request.reasoning_effort = Some(effort.into()); + self + } + + pub fn response_format(mut self, response_format: serde_json::Value) -> Self { + self.request.response_format = Some(response_format); + self + } + + pub fn search_parameters(mut self, search_parameters: SearchParameters) -> Self { + self.request.search_parameters = Some(search_parameters); + self + } + + pub fn stream_options(mut self, stream_options: StreamOptions) -> Self { + self.request.stream_options = Some(stream_options); + self + } + + pub fn tool_choice(mut self, tool_choice: serde_json::Value) -> Self { + self.request.tool_choice = Some(tool_choice); + self + } + + pub fn tools(mut self, tools: Vec) -> Self { + self.request.tools = Some(tools); + self + } + + pub fn web_search_options(mut self, web_search_options: serde_json::Value) -> Self { + self.request.web_search_options = Some(web_search_options); + self + } + pub fn build(self) -> Result { Ok(self.request) } @@ -203,3 +443,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..c3aeb86 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,173 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! CLI argument definitions for the x-ai command-line tool. + +#[cfg(feature = "cli")] +use clap::builder::styling::{AnsiColor, Effects, Styles}; +#[cfg(feature = "cli")] +use clap::{Args, Parser, Subcommand}; + +#[cfg(feature = "cli")] +fn styles() -> Styles { + Styles::styled() + .header(AnsiColor::Cyan.on_default() | Effects::BOLD) + .usage(AnsiColor::Cyan.on_default() | Effects::BOLD) + .literal(AnsiColor::Blue.on_default() | Effects::BOLD) + .error(AnsiColor::Red.on_default() | Effects::BOLD) + .placeholder(AnsiColor::Green.on_default()) +} + +#[cfg(feature = "cli")] +#[derive(Parser, Debug, Clone)] +#[command( + author = "Mahmoud Harmouch", + version, + name = "xai", + propagate_version = true, + styles = styles(), + help_template = r#"{about} +{usage-heading} {usage} + +{all-args}{after-help} + +AUTHORS: + {author} +"#, + about = r#" + █████ █████ █████████ █████ +▒▒███ ▒▒███ ███▒▒▒▒▒███ ▒▒███ + ▒▒███ ███ ▒███ ▒███ ▒███ + ▒▒█████ ▒███████████ ▒███ + ███▒███ ▒███▒▒▒▒▒███ ▒███ + ███ ▒▒███ ▒███ ▒███ ▒███ + █████ █████ █████ █████ █████ +▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒ + +A command-line tool for interacting with the xAI Grok API. + +FUNCTIONALITIES: + - Chat Completions : Chat with Grok models. + - Text Completions : Legacy text completions. + - Embeddings : Create text embeddings. + - List Models : List all available models. + - Get Model : Get info about a specific model. + - API Key Info : Inspect the current API key. + +USAGE: + xai [OPTIONS] + +EXAMPLES: + Run TUI (default): + xai + + Chat: + xai chat -t "What is the answer to life?" + + Stream chat: + xai chat -t "Tell me a story" --stream + + Text completion (legacy): + xai complete -p "Once upon a time" + + Create embeddings: + xai embed -t "Hello world" + + List models: + xai models + + Get model info: + xai model -m grok-4 + + API key info: + xai apikey + +For more information, visit: github.com/wiseaidotdev/x-ai +"# +)] +pub struct Cli { + /// xAI API key (overrides XAI_API_KEY env var). + #[arg(short = 'k', long)] + pub api_key: Option, + /// Model to use (default: grok-4). + #[arg(short, long)] + pub model: Option, + #[command(subcommand)] + pub cmd: Option, +} + +#[cfg(feature = "cli")] +#[derive(Subcommand, Debug, Clone)] +pub enum Command { + /// Chat with a Grok model. + Chat(Chat), + /// Legacy text completion. + Complete(Complete), + /// Create text embeddings. + Embed(Embed), + /// List all available models. + Models(Models), + /// Get details for a specific model. + Model(ModelInfo), + /// Show API key information. + Apikey(Apikey), +} + +#[cfg(feature = "cli")] +#[derive(Args, Debug, Clone)] +pub struct Chat { + /// The message to send. + #[arg(short, long)] + pub text: String, + /// Enable streaming output (typed character-by-character). + #[arg(short, long, default_value_t = false)] + pub stream: bool, +} + +#[cfg(feature = "cli")] +#[derive(Args, Debug, Clone)] +pub struct Complete { + /// The prompt text. + #[arg(short, long)] + pub prompt: String, + /// Maximum tokens to generate. + #[arg(long, default_value_t = 256)] + pub max_tokens: u32, +} + +#[cfg(feature = "cli")] +#[derive(Args, Debug, Clone)] +pub struct Embed { + /// Text to embed. + #[arg(short, long)] + pub text: String, + /// Encoding format (default: float). + #[arg(short, long, default_value = "float")] + pub encoding_format: String, +} + +#[cfg(feature = "cli")] +#[derive(Args, Debug, Clone)] +pub struct Models {} + +#[cfg(feature = "cli")] +#[derive(Args, Debug, Clone)] +pub struct ModelInfo { + /// Model ID to look up. + #[arg(short, long)] + pub model_id: String, +} + +#[cfg(feature = "cli")] +#[derive(Args, Debug, Clone)] +pub struct Apikey {} +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/client.rs b/src/client.rs index b7bf98a..ddcfe76 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,6 +1,13 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use crate::XAI_V1_URL; use crate::error::XaiError; use crate::traits::ClientConfig; -use crate::XAI_V1_URL; use reqwest::{Client as HttpClient, Method, RequestBuilder}; use std::sync::{Arc, RwLock}; @@ -59,3 +66,9 @@ impl XaiClientBuilder { }) } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/completions.rs b/src/completions.rs index 2263697..455d527 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -1,7 +1,14 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: [docs/api-reference#completions-legacy](https://docs.x.ai/docs/api-reference#completions-legacy) -use crate::error::check_for_model_error; use crate::error::XaiError; +use crate::error::check_for_model_error; use crate::traits::{ClientConfig, CompletionsFetcher}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -218,3 +225,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/deferred_compl.rs b/src/deferred_compl.rs new file mode 100644 index 0000000..d7fc604 --- /dev/null +++ b/src/deferred_compl.rs @@ -0,0 +1,78 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Reference: [docs/api-reference#deferred-completion](https://docs.x.ai/docs/api-reference#deferred-completion) +//! +//! Implements `GET /v1/chat/deferred-completion/{request_id}`. +//! Returns `200 OK` with the completion when ready, or `202 Accepted` while still processing. + +use crate::chat_compl::ChatCompletionResponse; +use crate::error::XaiError; +use crate::error::check_for_model_error; +use crate::traits::{ClientConfig, DeferredCompletionFetcher}; +use reqwest::Method; + +#[derive(Debug, Clone)] +pub struct DeferredCompletionRequestBuilder { + client: T, + request_id: String, +} + +impl DeferredCompletionRequestBuilder +where + T: ClientConfig + Clone + Send + Sync, +{ + pub fn new(client: T, request_id: impl Into) -> Self { + Self { + client, + request_id: request_id.into(), + } + } + + pub fn build(self) -> Result { + if self.request_id.trim().is_empty() { + return Err(XaiError::Validation( + "request_id cannot be empty".to_string(), + )); + } + Ok(self.request_id) + } +} + +impl DeferredCompletionFetcher for DeferredCompletionRequestBuilder +where + T: ClientConfig + Clone + Send + Sync, +{ + async fn fetch_deferred_completion( + &self, + request_id: &str, + ) -> Result, XaiError> { + let url = format!("chat/deferred-completion/{}", request_id); + let response = self.client.request(Method::GET, &url)?.send().await?; + + match response.status().as_u16() { + 200 => { + let completion = response.json::().await?; + Ok(Some(completion)) + } + 202 => Ok(None), + _ => { + let error_body = response.text().await.unwrap_or_else(|_| "".to_string()); + if let Some(model_error) = check_for_model_error(&error_body) { + return Err(model_error); + } + Err(XaiError::Http(error_body)) + } + } + } +} +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/embedding.rs b/src/embedding.rs index 3eea10a..d46df2f 100644 --- a/src/embedding.rs +++ b/src/embedding.rs @@ -1,7 +1,14 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: https://docs.x.ai/api/endpoints#create-embeddings -use crate::error::check_for_model_error; use crate::error::XaiError; +use crate::error::check_for_model_error; use crate::traits::{ClientConfig, EmbeddingFetcher}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -88,3 +95,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/embedding_get.rs b/src/embedding_get.rs index 2b71ca8..279b614 100644 --- a/src/embedding_get.rs +++ b/src/embedding_get.rs @@ -1,7 +1,14 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: https://docs.x.ai/api/endpoints#get-embedding-model -use crate::error::check_for_model_error; use crate::error::XaiError; +use crate::error::check_for_model_error; use crate::traits::{ClientConfig, EmbeddingModelFetcher}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -64,3 +71,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/embedding_mod.rs b/src/embedding_mod.rs index 99b3c16..7ae5823 100644 --- a/src/embedding_mod.rs +++ b/src/embedding_mod.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: https://docs.x.ai/api/endpoints#list-embedding-models use crate::error::XaiError; @@ -6,7 +13,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EmbeddingModelsResponse { - pub models: Vec, + pub data: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -35,9 +42,17 @@ where let models_response = response.json::().await?; Ok(models_response) } else { - Err(XaiError::Http( - response.error_for_status().unwrap_err().to_string(), - )) + let error_body = response.text().await.unwrap_or_else(|_| "".to_string()); + if let Some(model_error) = crate::error::check_for_model_error(&error_body) { + return Err(model_error); + } + Err(XaiError::Http(error_body)) } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/error.rs b/src/error.rs index dfcb10a..bc899f7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use thiserror::Error; #[derive(Error, Debug, Clone)] @@ -55,3 +62,9 @@ pub fn check_for_model_error(response: &str) -> Option { None } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/get_mod.rs b/src/get_mod.rs index cc8247c..eb973d1 100644 --- a/src/get_mod.rs +++ b/src/get_mod.rs @@ -1,7 +1,14 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: [docs/api-reference#get-model](https://docs.x.ai/docs/api-reference#get-model) -use crate::error::check_for_model_error; use crate::error::XaiError; +use crate::error::check_for_model_error; use crate::traits::{ClientConfig, ModelInfoFetcher}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -53,3 +60,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/lang_mod.rs b/src/lang_mod.rs index f2a40c3..5b1bfb6 100644 --- a/src/lang_mod.rs +++ b/src/lang_mod.rs @@ -1,7 +1,14 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: [docs/api-reference#get-language-model](https://docs.x.ai/docs/api-reference#get-language-model) -use crate::error::check_for_model_error; use crate::error::XaiError; +use crate::error::check_for_model_error; use crate::traits::{ClientConfig, GetModelFetcher}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -66,3 +73,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/lib.rs b/src/lib.rs index 2ed162d..9690e2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,18 @@ -#![cfg_attr(docsrs, feature(doc_auto_cfg))] +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +#![cfg_attr(docsrs, feature(doc_cfg))] #![doc = include_str!("../README.md")] pub mod api_key; pub mod chat_compl; pub mod client; pub mod completions; +pub mod deferred_compl; pub mod embedding; pub mod embedding_get; pub mod embedding_mod; @@ -13,6 +21,18 @@ pub mod get_mod; pub mod lang_mod; pub mod list_lang_mod; pub mod list_mod; +pub mod responses; pub mod traits; +#[cfg(feature = "cli")] +pub mod cli; +#[cfg(feature = "cli")] +pub mod tui; + pub const XAI_V1_URL: &str = "https://api.x.ai/v1"; +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/list_lang_mod.rs b/src/list_lang_mod.rs index e316abe..cb9121d 100644 --- a/src/list_lang_mod.rs +++ b/src/list_lang_mod.rs @@ -1,7 +1,14 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: [docs/api-reference#list-language-models](https://docs.x.ai/docs/api-reference#list-language-models) -use crate::error::check_for_model_error; use crate::error::XaiError; +use crate::error::check_for_model_error; use crate::traits::{ClientConfig, ModelFetcher}; use reqwest::Method; @@ -63,3 +70,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/list_mod.rs b/src/list_mod.rs index 28c19c2..186b874 100644 --- a/src/list_mod.rs +++ b/src/list_mod.rs @@ -1,7 +1,14 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + //! Reference: [docs/api-reference#list-models](https://docs.x.ai/docs/api-reference#list-models) -use crate::error::check_for_model_error; use crate::error::XaiError; +use crate::error::check_for_model_error; use crate::traits::{ClientConfig, ListModelFetcher}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -56,3 +63,9 @@ where } } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..76583e1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,147 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Entry point for the `xai` binary. +//! +//! Run without a subcommand to launch the interactive TUI. +//! Run with a subcommand to perform a single operation and exit. +//! +//! Requires the `cli` feature: +//! ```shell +//! cargo run --features cli -- chat -t "Hello Grok" +//! ``` + +fn main() { + #[cfg(feature = "cli")] + { + use tokio::runtime::Runtime; + let rt = Runtime::new().expect("Failed to create Tokio runtime"); + if let Err(e) = rt.block_on(run()) { + eprintln!("Error: {e}"); + std::process::exit(1); + } + } + + #[cfg(not(feature = "cli"))] + { + eprintln!("This binary requires the `cli` feature. Rebuild with:"); + eprintln!(" cargo build --features cli"); + } +} + +#[cfg(feature = "cli")] +async fn run() -> anyhow::Result<()> { + use clap::Parser; + use std::env; + use x_ai::api_key::ApiKeyRequestBuilder; + use x_ai::chat_compl::{ChatCompletionsRequestBuilder, Message}; + use x_ai::cli::{Cli, Command}; + use x_ai::client::XaiClient; + use x_ai::completions::CompletionsRequestBuilder; + use x_ai::embedding::EmbeddingRequestBuilder; + use x_ai::get_mod::ModelRequestBuilder; + use x_ai::list_mod::ReducedModelListRequestBuilder; + use x_ai::traits::{ + ApiKeyFetcher, ChatCompletionsFetcher, ClientConfig, CompletionsFetcher, EmbeddingFetcher, + ListModelFetcher, ModelInfoFetcher, + }; + use x_ai::tui::run_tui; + + let args = Cli::parse(); + + let api_key = args + .api_key + .unwrap_or_else(|| env::var("XAI_API_KEY").unwrap_or_default()); + + let model = args + .model + .unwrap_or_else(|| env::var("XAI_MODEL").unwrap_or_else(|_| "grok-4".to_string())); + + let client = XaiClient::builder().build()?; + client.set_api_key(api_key.clone()); + + match args.cmd { + Some(Command::Chat(cmd)) => { + let messages = vec![Message::text("user", cmd.text)]; + let builder = + ChatCompletionsRequestBuilder::new(client.clone(), model.clone(), messages); + let request = builder.clone().build()?; + let resp = builder.create_chat_completion(request).await?; + let content = resp + .choices + .first() + .map(|c| c.message.content.to_string()) + .unwrap_or_default(); + println!("{}", content); + } + + Some(Command::Complete(cmd)) => { + let builder = CompletionsRequestBuilder::new(client.clone(), model.clone(), cmd.prompt) + .max_tokens(cmd.max_tokens); + let request = builder.clone().build()?; + let resp = builder.create_completions(request).await?; + let text = resp + .choices + .first() + .map(|c| c.text.clone()) + .unwrap_or_default(); + println!("{}", text); + } + + Some(Command::Embed(cmd)) => { + let builder = EmbeddingRequestBuilder::new( + client.clone(), + model.clone(), + vec![cmd.text], + cmd.encoding_format, + ); + let request = builder.clone().build()?; + let resp = builder.create_embedding(request).await?; + println!("{:?}", resp.data); + } + + Some(Command::Models(_)) => { + let builder = ReducedModelListRequestBuilder::new(client.clone()); + let resp = builder.fetch_model_info().await?; + for m in resp.data { + println!(" • {} (owned by: {})", m.id, m.owned_by); + } + } + + Some(Command::Model(cmd)) => { + let builder = ModelRequestBuilder::new(client.clone(), cmd.model_id); + let info = builder.fetch_model_info().await?; + println!("ID: {}", info.id); + println!("Object: {}", info.object); + println!("Owned by: {}", info.owned_by); + println!("Created: {}", info.created); + } + + Some(Command::Apikey(_)) => { + let builder = ApiKeyRequestBuilder::new(client.clone()); + let info = builder.fetch_api_key_info().await?; + println!("ID: {}", info.api_key_id); + println!("Name: {}", info.name); + println!("Blocked: {}", info.api_key_blocked); + println!("Disabled: {}", info.api_key_disabled); + println!("Team: {}", info.team_id); + println!("ACLs: {}", info.acls.join(", ")); + } + + None => { + run_tui().await?; + } + } + + Ok(()) +} +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/responses.rs b/src/responses.rs new file mode 100644 index 0000000..31133bd --- /dev/null +++ b/src/responses.rs @@ -0,0 +1,409 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Reference: [docs/api-reference#responses](https://docs.x.ai/docs/api-reference#responses) +//! +//! Implements: +//! - `POST /v1/responses` - create a response +//! - `GET /v1/responses/{response_id}` - retrieve a stored response +//! - `DELETE /v1/responses/{response_id}` - delete a stored response + +use crate::chat_compl::SearchParameters; +use crate::error::XaiError; +use crate::error::check_for_model_error; +use crate::traits::{ClientConfig, ResponsesFetcher}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +/// Input to a `/v1/responses` request - either a plain string or a structured +/// list of message objects. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseInput { + Text(String), + Messages(Vec), +} + +/// Reasoning configuration for thinking models. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReasoningConfig { + /// `"low"` | `"medium"` | `"high"` + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + /// Compatibility field - ignored by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_summary: Option, + /// Compatibility field - `"auto"` | `"concise"` | `"detailed"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateResponseRequest { + pub model: String, + pub input: ResponseInput, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_response_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_turns: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search_parameters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallel_tool_calls: Option, + /// Output text format configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logprobs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_logprobs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + /// Not executed yet - parsed for compatibility. + #[serde(skip_serializing_if = "Option::is_none")] + pub context_management: Option>, + /// Which additional data to include (`"reasoning.encrypted_content"`). + #[serde(skip_serializing_if = "Option::is_none")] + pub include: Option>, + /// Compatibility only - not supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + /// Compatibility only - not supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub truncation: Option, + /// Compatibility only - not supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseInputTokensDetails { + pub cached_tokens: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseOutputTokensDetails { + pub reasoning_tokens: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerSideToolUsageDetails { + pub code_interpreter_calls: u32, + pub document_search_calls: u32, + pub file_search_calls: u32, + pub mcp_calls: u32, + pub web_search_calls: u32, + pub x_search_calls: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseUsage { + pub input_tokens: u32, + pub output_tokens: u32, + pub total_tokens: u32, + pub input_tokens_details: ResponseInputTokensDetails, + pub output_tokens_details: ResponseOutputTokensDetails, + pub num_sources_used: u32, + pub num_server_side_tools_used: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_in_usd_ticks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_in_nano_usd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_side_tool_usage_details: Option, +} + +/// A response object returned by POST/GET `/v1/responses`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseObject { + pub id: String, + pub object: String, + pub model: String, + /// `"completed"` | `"in_progress"` | `"incomplete"` + pub status: String, + pub created_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + /// The generated output items (messages, reasoning blocks, tool calls, etc.) + pub output: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub previous_response_id: Option, + pub store: bool, + pub parallel_tool_calls: bool, + pub tool_choice: serde_json::Value, + pub tools: Vec, + pub text: serde_json::Value, + pub truncation: String, + pub service_tier: String, + pub top_logprobs: u32, + pub frequency_penalty: f64, + pub presence_penalty: f64, + pub metadata: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub safety_identifier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub incomplete_details: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub background: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tool_calls: Option, +} + +/// Response body for `DELETE /v1/responses/{response_id}`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteResponseObject { + pub id: String, + pub object: String, + pub deleted: bool, +} + +#[derive(Debug, Clone)] +pub struct ResponsesRequestBuilder { + client: T, + request: CreateResponseRequest, +} + +impl ResponsesRequestBuilder +where + T: ClientConfig + Clone + Send + Sync, +{ + pub fn new(client: T, model: impl Into, input: ResponseInput) -> Self { + Self { + client, + request: CreateResponseRequest { + model: model.into(), + input, + instructions: None, + previous_response_id: None, + store: None, + stream: None, + temperature: None, + top_p: None, + max_output_tokens: None, + max_turns: None, + reasoning: None, + search_parameters: None, + tools: None, + tool_choice: None, + parallel_tool_calls: None, + text: None, + logprobs: None, + top_logprobs: None, + user: None, + context_management: None, + include: None, + service_tier: None, + truncation: None, + background: None, + prompt_cache_key: None, + metadata: None, + }, + } + } + + pub fn instructions(mut self, instructions: impl Into) -> Self { + self.request.instructions = Some(instructions.into()); + self + } + + pub fn previous_response_id(mut self, id: impl Into) -> Self { + self.request.previous_response_id = Some(id.into()); + self + } + + pub fn store(mut self, store: bool) -> Self { + self.request.store = Some(store); + self + } + + pub fn stream(mut self, stream: bool) -> Self { + self.request.stream = Some(stream); + self + } + + pub fn temperature(mut self, temperature: f32) -> Self { + self.request.temperature = Some(temperature); + self + } + + pub fn top_p(mut self, top_p: f32) -> Self { + self.request.top_p = Some(top_p); + self + } + + pub fn max_output_tokens(mut self, max_output_tokens: u32) -> Self { + self.request.max_output_tokens = Some(max_output_tokens); + self + } + + pub fn max_turns(mut self, max_turns: u32) -> Self { + self.request.max_turns = Some(max_turns); + self + } + + pub fn reasoning(mut self, reasoning: ReasoningConfig) -> Self { + self.request.reasoning = Some(reasoning); + self + } + + pub fn search_parameters(mut self, search_parameters: SearchParameters) -> Self { + self.request.search_parameters = Some(search_parameters); + self + } + + pub fn tools(mut self, tools: Vec) -> Self { + self.request.tools = Some(tools); + self + } + + pub fn tool_choice(mut self, tool_choice: serde_json::Value) -> Self { + self.request.tool_choice = Some(tool_choice); + self + } + + pub fn parallel_tool_calls(mut self, parallel_tool_calls: bool) -> Self { + self.request.parallel_tool_calls = Some(parallel_tool_calls); + self + } + + pub fn logprobs(mut self, logprobs: bool) -> Self { + self.request.logprobs = Some(logprobs); + self + } + + pub fn top_logprobs(mut self, top_logprobs: u32) -> Self { + self.request.top_logprobs = Some(top_logprobs); + self + } + + pub fn user(mut self, user: impl Into) -> Self { + self.request.user = Some(user.into()); + self + } + + pub fn include(mut self, include: Vec) -> Self { + self.request.include = Some(include); + self + } + + pub fn prompt_cache_key(mut self, key: impl Into) -> Self { + self.request.prompt_cache_key = Some(key.into()); + self + } + + pub fn build(self) -> Result { + if self.request.model.trim().is_empty() { + return Err(XaiError::Validation("model cannot be empty".to_string())); + } + Ok(self.request) + } +} + +impl ResponsesFetcher for ResponsesRequestBuilder +where + T: ClientConfig + Clone + Send + Sync, +{ + async fn create_response( + &self, + request: CreateResponseRequest, + ) -> Result { + let response = self + .client + .request(Method::POST, "responses")? + .json(&request) + .send() + .await?; + + if response.status().is_success() { + let obj = response.json::().await?; + Ok(obj) + } else { + let error_body = response.text().await.unwrap_or_else(|_| "".to_string()); + if let Some(model_error) = check_for_model_error(&error_body) { + return Err(model_error); + } + Err(XaiError::Http(error_body)) + } + } + + async fn get_response(&self, response_id: &str) -> Result { + let url = format!("responses/{}", response_id); + let response = self.client.request(Method::GET, &url)?.send().await?; + + if response.status().is_success() { + let obj = response.json::().await?; + Ok(obj) + } else { + let error_body = response.text().await.unwrap_or_else(|_| "".to_string()); + if let Some(model_error) = check_for_model_error(&error_body) { + return Err(model_error); + } + Err(XaiError::Http(error_body)) + } + } + + async fn delete_response(&self, response_id: &str) -> Result { + let url = format!("responses/{}", response_id); + let response = self.client.request(Method::DELETE, &url)?.send().await?; + + if response.status().is_success() { + let obj = response.json::().await?; + Ok(obj) + } else { + let error_body = response.text().await.unwrap_or_else(|_| "".to_string()); + if let Some(model_error) = check_for_model_error(&error_body) { + return Err(model_error); + } + Err(XaiError::Http(error_body)) + } + } +} +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/traits.rs b/src/traits.rs index e89780d..dd0c78f 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + #![allow(async_fn_in_trait)] use crate::api_key::ApiKeyInfo; @@ -14,6 +21,7 @@ use crate::get_mod::ModelInfoResponse; use crate::lang_mod::LanguageModelDetailResponse; use crate::list_lang_mod::LanguageModelListResponse; use crate::list_mod::ReducedModelListResponse; +use crate::responses::{CreateResponseRequest, DeleteResponseObject, ResponseObject}; use reqwest::{Method, RequestBuilder}; pub trait ClientConfig { @@ -70,3 +78,25 @@ pub trait ListModelFetcher { pub trait ModelInfoFetcher { async fn fetch_model_info(&self) -> Result; } + +pub trait DeferredCompletionFetcher { + async fn fetch_deferred_completion( + &self, + request_id: &str, + ) -> Result, XaiError>; +} + +pub trait ResponsesFetcher { + async fn create_response( + &self, + request: CreateResponseRequest, + ) -> Result; + async fn get_response(&self, response_id: &str) -> Result; + async fn delete_response(&self, response_id: &str) -> Result; +} +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..1a91362 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,523 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! TUI (terminal user interface) for the x-ai SDK. +//! +//! Launch with: `xai` (no subcommand). +//! Requires the `cli` feature. + +use anyhow::Result; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + Frame, Terminal, + backend::{Backend, CrosstermBackend}, + layout::{Constraint, Direction, Layout}, + prelude::{Alignment, Constraint::Max, Position, Stylize}, + style::{Color, Modifier, Style, palette::tailwind}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Tabs, Wrap}, +}; +use strum::IntoEnumIterator; +use strum_macros::{Display as DeriveDisplay, EnumIter as DeriveEnumIter}; +use tui_input::{Input, backend::crossterm::EventHandler as InputHandler}; + +use crate::chat_compl::{ChatCompletionsRequestBuilder, Message}; +use crate::client::XaiClient; +use crate::list_mod::ReducedModelListRequestBuilder; +use crate::traits::{ChatCompletionsFetcher, ClientConfig, ListModelFetcher}; + +use std::env; +use std::io; + +#[derive(Debug, Clone, DeriveEnumIter, DeriveDisplay, PartialEq)] +enum Tab { + #[strum(to_string = "🔐 Settings")] + Settings, + #[strum(to_string = "💬 Chat")] + Chat, + #[strum(to_string = "📜 History")] + History, + #[strum(to_string = "⚙️ Info")] + Info, +} + +impl Tab { + fn next(self) -> Self { + let mut iter = Tab::iter().cycle(); + while let Some(tab) = iter.next() { + if tab == self { + return iter.next().unwrap_or(self); + } + } + self + } + + fn previous(self) -> Self { + let tabs: Vec<_> = Tab::iter().collect(); + let idx = tabs.iter().position(|t| *t == self).unwrap_or(0); + if idx == 0 { + tabs.last().cloned().unwrap_or(self) + } else { + tabs[idx - 1].clone() + } + } +} + +#[derive(Debug, Clone, PartialEq)] +enum InputMode { + Normal, + Editing, +} + +#[derive(Debug, Clone, PartialEq)] +enum CurrentInput { + ApiKey, + SelectedModel, + ChatInput, +} + +struct App { + input_mode: InputMode, + selected_tab: Tab, + api_key: Input, + selected_model: Input, + user_input: Input, + client: Option, + chat_history: Vec>, + current_input: Option, + scroll_chat: u16, + info_lines: Vec>, +} + +impl Default for App { + fn default() -> Self { + let api_key_val = env::var("XAI_API_KEY").unwrap_or_default(); + Self { + input_mode: InputMode::Normal, + selected_tab: Tab::Settings, + api_key: Input::new(api_key_val), + selected_model: Input::new("grok-4".to_string()), + user_input: Input::default(), + chat_history: vec![], + current_input: None, + client: None, + scroll_chat: 0, + info_lines: vec![], + } + } +} + +pub async fn run_tui() -> Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let res = run_app(&mut terminal).await; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(e) = res { + eprintln!("{:?}", e); + } + Ok(()) +} + +async fn run_app(terminal: &mut Terminal) -> Result<()> { + let mut app = App::default(); + + if !app.api_key.value().is_empty() + && let Ok(client) = XaiClient::builder().build() + { + client.set_api_key(app.api_key.value().to_string()); + app.client = Some(client); + } + + loop { + terminal.draw(|f| ui(f, &mut app))?; + + if let Event::Key(key) = event::read()? { + match app.input_mode { + InputMode::Normal => match key.code { + KeyCode::Char('q') => break, + KeyCode::Char('e') => { + app.input_mode = InputMode::Editing; + match app.selected_tab { + Tab::Settings => app.current_input = Some(CurrentInput::ApiKey), + Tab::Chat => app.current_input = Some(CurrentInput::ChatInput), + _ => app.current_input = None, + } + } + KeyCode::Right | KeyCode::Char('d') => { + app.selected_tab = app.selected_tab.next() + } + KeyCode::Left | KeyCode::Char('a') => { + app.selected_tab = app.selected_tab.previous() + } + KeyCode::Up if app.scroll_chat > 0 => app.scroll_chat -= 1, + KeyCode::Down => app.scroll_chat += 1, + KeyCode::PageUp => app.scroll_chat = app.scroll_chat.saturating_sub(5), + KeyCode::PageDown => app.scroll_chat += 5, + _ => {} + }, + InputMode::Editing => match key.code { + KeyCode::Esc => app.input_mode = InputMode::Normal, + KeyCode::Enter => match app.selected_tab { + Tab::Settings => { + let api_key = app.api_key.value().to_string(); + match XaiClient::builder().build() { + Ok(client) => { + client.set_api_key(api_key); + app.client = Some(client); + app.input_mode = InputMode::Normal; + } + Err(e) => { + app.chat_history.push( + Line::from(format!("Error building client: {e}")) + .style(Style::default().fg(Color::Red)), + ); + } + } + } + Tab::Chat => { + app.scroll_chat = 0; + let msg = app.user_input.value().to_string(); + if msg.trim().is_empty() { + continue; + } + + app.chat_history.push( + Line::from(vec![Span::styled( + format!("🧑 You: {}", msg), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )]) + .alignment(Alignment::Right), + ); + + if let Some(ref client) = app.client { + let builder = ChatCompletionsRequestBuilder::new( + client.clone(), + app.selected_model.value().to_string(), + vec![Message::text("user", msg.clone())], + ); + match builder.clone().build() { + Ok(req) => match builder.create_chat_completion(req).await { + Ok(resp) => { + let content = resp + .choices + .first() + .map(|c| c.message.content.to_string()) + .unwrap_or_default(); + app.chat_history.push( + Line::from(vec![ + Span::styled( + "🤖 Grok: ", + Style::default() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(content), + ]) + .alignment(Alignment::Left), + ); + } + Err(e) => { + app.chat_history.push( + Line::from(format!("Error: {e}")) + .style(Style::default().fg(Color::Red)), + ); + } + }, + Err(e) => { + app.chat_history.push( + Line::from(format!("Request error: {e}")) + .style(Style::default().fg(Color::Red)), + ); + } + } + } else { + app.chat_history.push( + Line::from( + "⚠ No client configured. Go to Settings and enter your API key.", + ) + .style(Style::default().fg(Color::Yellow)), + ); + } + app.user_input.reset(); + } + Tab::Info => { + if let Some(ref client) = app.client { + let builder = ReducedModelListRequestBuilder::new(client.clone()); + match builder.fetch_model_info().await { + Ok(resp) => { + app.info_lines.clear(); + app.info_lines.push( + Line::from("Available Models:").style( + Style::default().add_modifier(Modifier::BOLD), + ), + ); + for m in resp.data { + app.info_lines.push(Line::from(format!( + " • {} (owned by: {})", + m.id, m.owned_by + ))); + } + } + Err(e) => { + app.info_lines = vec![ + Line::from(format!("Error loading models: {e}")) + .style(Style::default().fg(Color::Red)), + ]; + } + } + } else { + app.info_lines = vec![ + Line::from("⚠ No client configured. Go to Settings first.") + .style(Style::default().fg(Color::Yellow)), + ]; + } + app.input_mode = InputMode::Normal; + } + _ => {} + }, + KeyCode::Tab => match app.current_input { + Some(CurrentInput::ApiKey) => { + app.current_input = Some(CurrentInput::SelectedModel); + } + Some(CurrentInput::SelectedModel) => { + app.current_input = Some(CurrentInput::ApiKey); + } + _ => {} + }, + _ => match app.selected_tab { + Tab::Settings => match app.current_input { + Some(CurrentInput::ApiKey) => { + app.api_key.handle_event(&Event::Key(key)); + } + Some(CurrentInput::SelectedModel) => { + app.selected_model.handle_event(&Event::Key(key)); + } + _ => {} + }, + Tab::Chat => { + let _ = app.user_input.handle_event(&Event::Key(key)); + } + _ => {} + }, + }, + } + } + } + Ok(()) +} + +fn ui(f: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(f.area()); + + let titles = Tab::iter().map(|t| { + Line::from(vec![Span::styled( + t.to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]) + }); + let tabs = Tabs::new(titles.collect::>()) + .select(app.selected_tab.clone() as usize) + .block(Block::default().borders(Borders::ALL).title(" ✨ X-AI ")) + .highlight_style(Style::default().fg(Color::Yellow)); + f.render_widget(tabs, chunks[0]); + + match app.selected_tab { + Tab::Settings => render_settings(f, app, chunks[1]), + Tab::Chat => render_chat(f, app, chunks[1]), + Tab::History => { + let para = Paragraph::new(app.chat_history.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title("📜 Full History"), + ) + .wrap(Wrap { trim: true }); + f.render_widget(para, chunks[1]); + } + Tab::Info => render_info(f, app, chunks[1]), + } + + let footer_layout = Layout::new(Direction::Vertical, [Max(1), Max(1), Max(1)]).split(chunks[2]); + + let keys = Line::raw( + "◄/► or a/d: tabs | e: edit | Tab: next field | Enter: confirm | Esc: cancel | q: quit", + ) + .centered(); + let credit = Line::raw("© Wise AI Foundation - xAI Grok SDK") + .fg(Color::LightGreen) + .bg(tailwind::SLATE.c700) + .bold() + .centered(); + + f.render_widget(keys, footer_layout[0]); + f.render_widget(credit, footer_layout[2]); +} + +fn render_settings(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(0), + ]) + .split(area); + + let scroll_key = app.api_key.visual_scroll(layout[0].width as usize); + let scroll_mod = app.selected_model.visual_scroll(layout[1].width as usize); + + let key_active = matches!(app.current_input, Some(CurrentInput::ApiKey)) + && app.input_mode == InputMode::Editing; + let mod_active = matches!(app.current_input, Some(CurrentInput::SelectedModel)) + && app.input_mode == InputMode::Editing; + + let api_widget = Paragraph::new(app.api_key.value()) + .style(if key_active { + Style::default().fg(Color::Green) + } else { + Style::default() + }) + .scroll((0, scroll_key as u16)) + .block( + Block::default() + .borders(Borders::ALL) + .title("🔑 xAI API Key"), + ); + + let model_widget = Paragraph::new(app.selected_model.value()) + .style(if mod_active { + Style::default().fg(Color::Green) + } else { + Style::default() + }) + .scroll((0, scroll_mod as u16)) + .block( + Block::default() + .borders(Borders::ALL) + .title("🤖 Model (default: grok-4)"), + ); + + f.render_widget(api_widget, layout[0]); + f.render_widget(model_widget, layout[1]); + + let hint = Paragraph::new(if app.client.is_some() { + "✅ Client configured. Press ◄ / ► to switch to Chat." + } else { + "Enter your API key and press Enter to save." + }) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(hint, layout[2]); + + if app.input_mode == InputMode::Editing { + match app.current_input { + Some(CurrentInput::ApiKey) => { + let x = layout[0].x + + ((app.api_key.visual_cursor()).max(scroll_key) - scroll_key) as u16 + + 1; + f.set_cursor_position(Position::new(x, layout[0].y + 1)); + } + Some(CurrentInput::SelectedModel) => { + let x = layout[1].x + + ((app.selected_model.visual_cursor()).max(scroll_mod) - scroll_mod) as u16 + + 1; + f.set_cursor_position(Position::new(x, layout[1].y + 1)); + } + _ => {} + } + } +} + +fn render_chat(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(area); + + let chat = Paragraph::new(app.chat_history.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title("💬 Conversation"), + ) + .wrap(Wrap { trim: true }) + .scroll((app.scroll_chat, 0)); + f.render_widget(chat, layout[0]); + + let scroll_input = app.user_input.visual_scroll(layout[1].width as usize); + let input_active = matches!(app.current_input, Some(CurrentInput::ChatInput)) + && app.input_mode == InputMode::Editing; + + let input = Paragraph::new(app.user_input.value()) + .style(if input_active { + Style::default().fg(Color::Green) + } else { + Style::default() + }) + .scroll((0, scroll_input as u16)) + .block( + Block::default() + .borders(Borders::ALL) + .title("Type your message (e → edit, Enter → send)"), + ); + f.render_widget(input, layout[1]); + + if input_active { + let x = layout[1].x + + ((app.user_input.visual_cursor()).max(scroll_input) - scroll_input) as u16 + + 1; + f.set_cursor_position(Position::new(x, layout[1].y + 1)); + } +} + +fn render_info(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + let content = if app.info_lines.is_empty() { + vec![Line::from("Press e → Enter to load available models.")] + } else { + app.info_lines.clone() + }; + let para = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .title("⚙️ Model Info"), + ) + .wrap(Wrap { trim: true }); + f.render_widget(para, area); +} +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/api_key.rs b/tests/api_key.rs index 7565676..0ab763b 100644 --- a/tests/api_key.rs +++ b/tests/api_key.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; use x_ai::api_key::ApiKeyRequestBuilder; use x_ai::client::XaiClient; @@ -5,6 +12,7 @@ use x_ai::traits::ApiKeyFetcher; use x_ai::traits::ClientConfig; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_fetch_api_key_info() { let client = XaiClient::builder() .build() @@ -25,12 +33,18 @@ async fn test_fetch_api_key_info() { let api_key_info = result.unwrap(); assert_eq!( api_key_info.api_key_id, - "06e3dd66-267d-4bfb-b0db-ae23015e7f61" + "89a3e16b-d93e-437f-9add-ec2cbd51296f" ); - assert_eq!(api_key_info.name, "Default"); + assert_eq!(api_key_info.name, "XAI Key"); assert!(!api_key_info.api_key_blocked); assert_eq!( api_key_info.acls, - vec!["api-key:model:*", "api-key:endpoint:*", ""] + vec!["api-key:endpoint:*", "api-key:model:*", ""] ); } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/chat_compl.rs b/tests/chat_compl.rs index 0041df6..cf6e547 100644 --- a/tests/chat_compl.rs +++ b/tests/chat_compl.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use x_ai::chat_compl::ChatCompletionsRequestBuilder; use x_ai::client::XaiClient; use x_ai::traits::ChatCompletionsFetcher; @@ -7,6 +14,7 @@ use x_ai::chat_compl::Message; use x_ai::traits::ClientConfig; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_chat_completion() { let client = XaiClient::builder() .build() @@ -19,19 +27,15 @@ async fn test_chat_completion() { ); let messages = vec![ - Message { - role: "system".to_string(), - content: "You are Grok, a chatbot inspired by the Hitchhikers Guide to the Galaxy." - .to_string(), - }, - Message { - role: "user".to_string(), - content: "What is the answer to life and universe?".to_string(), - }, + Message::text( + "system", + "You are Grok, a chatbot inspired by the Hitchhikers Guide to the Galaxy.", + ), + Message::text("user", "What is the answer to life and universe?"), ]; let request_builder = - ChatCompletionsRequestBuilder::new(client.clone(), "grok-beta".to_string(), messages) + ChatCompletionsRequestBuilder::new(client.clone(), "grok-4".to_string(), messages) .temperature(0.0) .stream(false); @@ -49,6 +53,12 @@ async fn test_chat_completion() { let completion = response.unwrap(); assert_eq!(completion.object, "chat.completion"); - assert!(completion.choices.len() > 0); + assert!(!completion.choices.is_empty()); assert_eq!(completion.choices[0].message.role, "assistant"); } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/completions.rs b/tests/completions.rs index deea1b6..10c9ebe 100644 --- a/tests/completions.rs +++ b/tests/completions.rs @@ -1,10 +1,18 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; +use x_ai::XAI_V1_URL; use x_ai::client::XaiClient; use x_ai::completions::CompletionsRequestBuilder; use x_ai::traits::{ClientConfig, CompletionsFetcher}; -use x_ai::XAI_V1_URL; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_create_completions() { let client = XaiClient::builder() .base_url(XAI_V1_URL) @@ -19,7 +27,7 @@ async fn test_create_completions() { let request_builder = CompletionsRequestBuilder::new( client.clone(), - "grok-beta".to_string(), + "grok-4".to_string(), "What is AI?".to_string(), ) .max_tokens(50) @@ -42,11 +50,10 @@ async fn test_create_completions() { let completions = response.unwrap(); assert_eq!(completions.object, "text_completion"); - assert_eq!(completions.model, "grok-beta"); - assert!(completions.choices.len() > 0, "No choices returned"); - + assert_eq!(completions.model, "grok-4"); let choice = &completions.choices[0]; - assert!(choice.text.len() > 0, "Choice text is empty"); + + assert!(!choice.text.is_empty(), "Choice text is empty"); assert!( matches!(choice.finish_reason.as_str(), "length" | "stop" | "null"), "Unexpected finish_reason" @@ -68,3 +75,9 @@ async fn test_create_completions() { ); } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/embedding.rs b/tests/embedding.rs index 81958e1..8fc1934 100644 --- a/tests/embedding.rs +++ b/tests/embedding.rs @@ -1,10 +1,18 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; +use x_ai::XAI_V1_URL; use x_ai::client::XaiClient; use x_ai::embedding::EmbeddingRequestBuilder; use x_ai::traits::{ClientConfig, EmbeddingFetcher}; -use x_ai::XAI_V1_URL; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_create_embedding() { let client = XaiClient::builder() .base_url(XAI_V1_URL) @@ -22,8 +30,8 @@ async fn test_create_embedding() { "How does machine learning work?".to_string(), ]; - // TODO: Figure out WTF Elon Musk named this model - let model = "grok-beta".to_string(); + // TODO: Figure out WTF Yilong Ma named this embedding model + let model = "grok-4".to_string(); let encoding_format = "float32".to_string(); let request_builder = EmbeddingRequestBuilder::new( @@ -64,3 +72,9 @@ async fn test_create_embedding() { // } // } } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/embedding_get.rs b/tests/embedding_get.rs index 28c0b44..b5e1d2a 100644 --- a/tests/embedding_get.rs +++ b/tests/embedding_get.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; use x_ai::client::XaiClient; use x_ai::embedding_get::EmbeddingModelRequestBuilder; @@ -5,6 +12,7 @@ use x_ai::traits::ClientConfig; use x_ai::traits::EmbeddingModelFetcher; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_fetch_embedding_model_info() { let client = XaiClient::builder() .build() @@ -35,3 +43,9 @@ async fn test_fetch_embedding_model_info() { // assert_eq!(model_info.prompt_text_token_price, 1); // assert_eq!(model_info.prompt_image_token_price, 2); } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/embedding_list.rs b/tests/embedding_list.rs index bc12aac..3cc90bd 100644 --- a/tests/embedding_list.rs +++ b/tests/embedding_list.rs @@ -1,9 +1,17 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; use x_ai::client::XaiClient; use x_ai::traits::ClientConfig; use x_ai::traits::EmbeddingModelsFetcher; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_list_embedding_models_endpoint() { let client = XaiClient::builder() .build() @@ -19,7 +27,7 @@ async fn test_list_embedding_models_endpoint() { assert!(result.is_ok()); let response = result.unwrap(); - assert_eq!(response.models.len(), 0); + assert!(!response.data.is_empty()); // TODO: Fix this after create_embedding endpoint // let model = &response.models[0]; @@ -32,3 +40,9 @@ async fn test_list_embedding_models_endpoint() { // assert_eq!(model.prompt_image_token_price, 0); // assert_eq!(model.prompt_text_token_price, 100); } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/get_mod.rs b/tests/get_mod.rs index 9f7bb9b..f27cff7 100644 --- a/tests/get_mod.rs +++ b/tests/get_mod.rs @@ -1,9 +1,17 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; use x_ai::client::XaiClient; use x_ai::get_mod::ModelRequestBuilder; use x_ai::traits::{ClientConfig, ModelInfoFetcher}; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_fetch_model_info() { let client = XaiClient::builder() .build() @@ -15,7 +23,7 @@ async fn test_fetch_model_info() { .to_string(), ); - let model_id = "grok-beta".to_string(); + let model_id = "grok-4".to_string(); let request_builder = ModelRequestBuilder::new(client, model_id); let result = request_builder.fetch_model_info().await; @@ -23,8 +31,14 @@ async fn test_fetch_model_info() { assert!(result.is_ok()); let model_info = result.unwrap(); - assert_eq!(model_info.id, "grok-beta"); + assert_eq!(model_info.id, "grok-4"); assert_eq!(model_info.object, "model"); assert_eq!(model_info.owned_by, "xai"); assert_eq!(model_info.created, 1727136000); } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/lang_mod.rs b/tests/lang_mod.rs index 52217b7..da520af 100644 --- a/tests/lang_mod.rs +++ b/tests/lang_mod.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; use x_ai::client::XaiClient; use x_ai::lang_mod::LanguageModelDetailRequestBuilder; @@ -5,6 +12,7 @@ use x_ai::traits::ClientConfig; use x_ai::traits::GetModelFetcher; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_fetch_language_model_detail() { let client = XaiClient::builder() .build() @@ -16,7 +24,7 @@ async fn test_fetch_language_model_detail() { .to_string(), ); - let model_id = "grok-beta"; + let model_id = "grok-4"; let request_builder = LanguageModelDetailRequestBuilder::new(client.clone(), model_id.to_string()); @@ -26,7 +34,7 @@ async fn test_fetch_language_model_detail() { assert!(result.is_ok()); let model_detail = result.unwrap(); - assert_eq!(model_detail.id, "grok-beta"); + assert_eq!(model_detail.id, "grok-4"); assert_eq!(model_detail.object, "model"); assert_eq!(model_detail.owned_by, "xai"); assert!(model_detail.input_modalities.contains(&"text".to_string())); @@ -36,3 +44,9 @@ async fn test_fetch_language_model_detail() { assert_eq!(model_detail.prompt_image_token_price, 0); assert_eq!(model_detail.version, "1.0.0"); } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/list_lang_mod.rs b/tests/list_lang_mod.rs index bcb8c0b..d2ff8ab 100644 --- a/tests/list_lang_mod.rs +++ b/tests/list_lang_mod.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; use x_ai::client::XaiClient; use x_ai::list_lang_mod::LanguageModelRequestBuilder; @@ -5,6 +12,7 @@ use x_ai::traits::ClientConfig; use x_ai::traits::ModelFetcher; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_fetch_language_models() { let client = XaiClient::builder() .build() @@ -26,7 +34,7 @@ async fn test_fetch_language_models() { assert!(!model_list.models.is_empty()); let first_model = &model_list.models[0]; - assert_eq!(first_model.id, "grok-beta"); + assert_eq!(first_model.id, "grok-4"); assert_eq!(first_model.object, "model"); assert_eq!(first_model.owned_by, "xai"); assert!(first_model.input_modalities.contains(&"text".to_string())); @@ -35,3 +43,9 @@ async fn test_fetch_language_models() { assert_eq!(first_model.prompt_text_token_price, 50000); assert_eq!(first_model.prompt_image_token_price, 0); } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. diff --git a/tests/list_mod.rs b/tests/list_mod.rs index 7325f5c..ef2bedd 100644 --- a/tests/list_mod.rs +++ b/tests/list_mod.rs @@ -1,3 +1,10 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + use std::env; use x_ai::client::XaiClient; use x_ai::list_mod::ReducedModelListRequestBuilder; @@ -5,6 +12,7 @@ use x_ai::traits::ClientConfig; use x_ai::traits::ListModelFetcher; #[tokio::test] +#[ignore = "This test requires a valid API key"] async fn test_fetch_reduced_model_list() { let client = XaiClient::builder() .build() @@ -26,7 +34,7 @@ async fn test_fetch_reduced_model_list() { assert!(!model_list.data.is_empty()); let first_model = &model_list.data[0]; - assert_eq!(first_model.id, "grok-beta"); + assert_eq!(first_model.id, "grok-4"); assert_eq!(first_model.object, "model"); assert_eq!(first_model.owned_by, "xai"); assert_eq!(first_model.created, 1727136000); @@ -37,3 +45,9 @@ async fn test_fetch_reduced_model_list() { assert_eq!(second_model.owned_by, "xai"); assert_eq!(second_model.created, 1730764800); } +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms.