English | 简体中文
RustGLM is an async Rust SDK for the Zhipu AI open platform. It covers the usual chat surface (GLM-5 typed requests, SSE streaming, ToolStream assembly) and then some: batch jobs, knowledge bases, file parsing and OCR, image/video generation, voice, a bidirectional Realtime WebSocket client, a local agent runtime with semantic memory, and a standalone MCP client built on rmcp.
I wrote it for backend services, so the crate keeps its hands off your machine. No hidden config file discovery, no implicit disk writes, no telemetry. Credentials, retries, persistence, and client lifetime are yours to control.
[dependencies]
rustglm = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }$env:ZHIPU_API_KEY = "key_id.secret"
cargo run --example chat_completionuse rustglm::{ChatCompletionRequest, ChatMessage, ZhipuClient};
# async fn run() -> rustglm::Result<()> {
let client = ZhipuClient::new("key_id.secret")?;
let request = ChatCompletionRequest::new("glm-5.3")
.message(ChatMessage::user("Explain ownership in one paragraph."));
let response = client.chat_completion(&request).await?;
println!("{}", response.text().unwrap_or_default());
# Ok(())
# }- Rust 1.88 or later (2024 edition)
- Tokio for anything async
- A Zhipu API key, or a bearer token you already obtained
A few ground rules so there are no surprises in production:
- No implicit disk I/O. File and RAG uploads take caller-owned bytes; the crate never opens a path itself.
- No implicit credentials. API keys are constructor arguments.
EnvironmentSecretResolveris an explicit opt-in agent utility, not magic. - No network traffic you didn't ask for. Constructing config values does zero I/O; requests fire only when you await an endpoint method.
- No telemetry, no metadata endpoints, no NTP. JWT signing only reads the local clock.
- HTTP retries are off by default. If you turn them on, only idempotent methods (GET, HEAD, DELETE, OPTIONS, PUT) are retried on connection/timeout failures, so a POST that already landed server-side isn't replayed blindly.
- Credentials only travel over TLS. A plaintext
http://base URL is rejected for non-local hosts unless you opt in withHttpConfig::allow_insecure(true)— same policy MCP and Realtime already had.
Default features cover the full Zhipu surface; the MCP client is opt-in because it pulls in rmcp.
| Feature | Default | What you get |
|---|---|---|
agents |
yes | Official agents, assistant endpoints, local agent runtime |
audio |
yes | GLM-4-Voice, transcription, speech, voice management |
batch |
yes | Typed Batch API: create, list, inspect, cancel |
files |
yes | Upload/download/delete, parsing, OCR, layout parsing |
images |
yes | Image generation |
mcp |
no | Standalone Streamable HTTP MCP client (rmcp) |
rag |
yes | Retrieval agent, knowledge bases, document management |
realtime |
yes | Typed bidirectional WebSocket client |
tools |
yes | Hosted tool types, web ops, ToolStream assembly |
video |
yes | Video generation |
full |
no | Everything, including mcp |
Minimal chat-only build:
[dependencies]
rustglm = { version = "1.0", default-features = false }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }For a bigger install, pick what you need:
[dependencies]
rustglm = {
version = "1.0",
default-features = false,
features = ["batch", "mcp", "rag", "realtime", "tools"]
}
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }ZhipuClient::new, ZhipuConfig::new, and RealtimeConfig::new all take credentials directly.
key_id.secretis treated as a combined API key and signed as an HS256 JWT (cached, refreshed before expiry).- Anything else non-empty is treated as an opaque bearer token.
- Use
ZhipuAuthentication::jwtorZhipuAuthentication::bearerwhen you need to be explicit.
Don't commit credentials. Read them from env, a secret manager, or a workload identity provider — that part is on your side.
Marker types and a sealed capability trait mean the typed API refuses unsupported fields at compile time. You can't send reasoning_effort to a model that doesn't support it, and a request can't reach the network before it has user or tool input.
use rustglm::{Glm53, ReasoningEffort, Thinking, TypedChatRequest, ZhipuClient};
# async fn run() -> rustglm::Result<()> {
let client = ZhipuClient::new("key_id.secret")?;
let request = TypedChatRequest::<Glm53>::new()
.system("Answer with evidence.")
.thinking(Thinking::enabled())
.reasoning_effort(ReasoningEffort::High)
.user("Summarize the incident report.");
let response = client.typed_chat_completion(&request).await?;
println!("{}", response.text().unwrap_or_default());
# Ok(())
# }The supported markers are listed below. For models that aren't in the table yet (new releases, private deployments), use ChatCompletionRequest directly with the model ID as a string.
| Text model | Marker | Thinking | Reasoning effort | ToolStream |
|---|---|---|---|---|
glm-5.3 |
Glm53 |
yes | yes | yes |
glm-5.2 |
Glm52 |
yes | yes | yes |
glm-5.1 |
Glm51 |
yes | no | yes |
glm-5.1-highspeed |
Glm51Highspeed |
yes | no | yes |
glm-5-turbo |
Glm5Turbo |
yes | no | yes |
glm-5 |
Glm5 |
yes | no | yes |
glm-4.7 |
Glm47 |
yes | no | yes |
glm-4.7-flash |
Glm47Flash |
yes | no | no |
glm-4.7-flashx |
Glm47FlashX |
yes | no | no |
glm-4.6 |
Glm46 |
yes | no | yes |
glm-4.5-air |
Glm45Air |
yes | no | no |
glm-4.5-airx |
Glm45AirX |
yes | no | no |
glm-4.5-flash |
Glm45Flash |
yes | no | no |
glm-4-flash-250414 |
Glm4Flash250414 |
no | no | no |
glm-4-flashx-250414 |
Glm4FlashX250414 |
no | no | no |
glm-4-long |
Glm4Long |
no | no | no |
charglm-4 |
Charglm4 |
no | no | no |
emohaa |
Emohaa |
no | no | no |
| Vision model | Marker | Thinking | ToolStream |
|---|---|---|---|
glm-5v-turbo |
Glm5vTurbo |
yes | no |
autoglm-phone |
AutoGlmPhone |
no | no |
glm-4.6v |
Glm46v |
yes | no |
glm-4.6v-flash |
Glm46vFlash |
yes | no |
glm-4.6v-flashx |
Glm46vFlashX |
yes | no |
glm-4v-flash |
Glm4vFlash |
no | no |
glm-4.1v-thinking-flash |
Glm41vThinkingFlash |
yes | no |
glm-4.1v-thinking-flashx |
Glm41vThinkingFlashX |
yes | no |
glm-ocr |
GlmOcr |
no | no |
glm-4.1v-thinking |
Glm41vThinking |
yes | no |
ReasoningEffort, Thinking, ToolStream, tools, and vision input only exist on markers that declare the capability, so an unsupported field never reaches the transport through the typed path.
ToolStream turns fragmented SSE function-call deltas into complete typed calls while keeping text, reasoning, usage, and stream errors in the same stream. It also enforces sane limits: arguments per call and concurrent pending calls are bounded, and a call can't be emitted as "complete" unless its arguments parse as valid JSON.
use futures_util::StreamExt;
use rustglm::{Glm53, ToolStreamEvent, TypedChatRequest, ZhipuClient};
# async fn run() -> rustglm::Result<()> {
let client = ZhipuClient::new("token")?;
let request = TypedChatRequest::<Glm53>::new()
.tool_stream()
.user("Check the deployment status.");
let mut stream = client.typed_chat_tool_stream(&request).await?;
while let Some(event) = stream.next().await {
if let ToolStreamEvent::ToolCallCompleted(call) = event? {
println!("{} {}", call.name, call.arguments);
}
}
# Ok(())
# }Beyond the official hosted agents, there's a self-hosted AgentRuntime that runs a manifest-driven loop over any ChatProvider: persona prompt, history policy, registered tools, and optional semantic memory. The runtime is defensible by default:
max_stepsis capped; total tool executions and per-tool output bytes are budgeted.- Optional
run_timeoutandtool_timeoutkeep a stuck model or a hung tool from blocking a request forever. - Recalled memory is injected as untrusted context, explicitly framed as data — never as instructions. This limits the blast radius of prompt-injection content that ends up in a memory store.
- Errors are structured (
StepLimit,BudgetExceeded,NoOutput,ToolError::NotRegistered, ...), so you can branch on them instead of string-matching.
use std::sync::Arc;
use rustglm::{AgentManifest, AgentPersona, AgentRuntime, ZhipuClient};
# async fn run() -> rustglm::Result<()> {
let client = Arc::new(ZhipuClient::new("token")?);
let manifest = AgentManifest::new(
"glm-5.3",
AgentPersona::new("Lin", "technical companion").language("English"),
);
let mut agent = AgentRuntime::new(client, manifest)?;
let result = agent.run("What is the status?").await?;
# let _ = result;
# Ok(())
# }The moderate_content method sends typed text, image, audio, or video content to the moderation model and returns structured risk results. Text input is capped at 2000 characters; media URLs must be absolute HTTP(S) URLs, checked before anything leaves your process.
use rustglm::{ModerationItem, ModerationRequest, ZhipuClient};
# async fn run() -> rustglm::Result<()> {
let client = ZhipuClient::new("token")?;
let response = client
.moderate_content(&ModerationRequest::new_items([
ModerationItem::text("Check this comment"),
ModerationItem::image_url("https://example.com/upload.png"),
]))
.await?;
for result in response.result_list.unwrap_or_default() {
println!("{:?} -> {:?}", result.content_type, result.risk_level);
}
# Ok(())
# }The batch feature provides typed completion windows and statuses. Batch input files are uploaded explicitly through the files API.
use rustglm::{BatchCreateRequest, ZhipuClient};
# async fn run() -> rustglm::Result<()> {
let client = ZhipuClient::new("token")?;
let request = BatchCreateRequest::new("input-file-id", "/v4/chat/completions");
let batch = client.create_batch(&request).await?;
let current = client.batch(&batch.id).await?;
println!("{:?}", current.status);
# Ok(())
# }Available methods are create_batch, batches, batch, and cancel_batch. List limits outside 1..=100 are rejected with BatchError::InvalidLimit before any network I/O.
The rag feature follows the official knowledge-base OpenAPI paths: knowledge-base CRUD, capacity, retrieval, document list/detail, in-memory file upload, URL ingestion, deletion, document images, and re-embedding. Upload and callback URLs are validated before they go anywhere — they must be absolute HTTP(S) URLs without embedded credentials.
use rustglm::{
DocumentChunking, KnowledgeCreateRequest, KnowledgeEmbeddingModel,
KnowledgeRetrieveRequest, RagDocumentUpload, UrlDocument,
UrlDocumentUploadRequest, ZhipuClient,
};
# async fn run() -> rustglm::Result<()> {
let client = ZhipuClient::new("token")?;
let created = client
.create_knowledge_base(&KnowledgeCreateRequest::new(
"engineering-runbooks",
KnowledgeEmbeddingModel::Embedding3Pro,
))
.await?;
let knowledge_id = created.data.expect("successful response").id;
client
.upload_knowledge_document(
&knowledge_id,
RagDocumentUpload::from_bytes("runbook.md", b"rollback procedure".to_vec()),
)
.await?;
let results = client
.retrieve_knowledge(&KnowledgeRetrieveRequest::new(
"How do we roll back?",
[knowledge_id],
))
.await?;
println!("{} matches", results.data.unwrap_or_default().len());
# Ok(())
# }RagDocumentUpload::from_bytes has no path-based constructor on purpose. File reads, size limits, encryption, tenant boundaries, and retention stay in your hands.
The mcp feature is a standalone Model Context Protocol client (separate from McpTool, which configures a hosted MCP tool inside a model request). Protocol framing, initialization, tools, resources, prompts, and the Streamable HTTP transport come from the official rmcp crate.
use rustglm::McpClientConfig;
# async fn run() -> rustglm::Result<()> {
let mut client = McpClientConfig::new("https://mcp.example.com/mcp")
.bearer_token("tenant-token")
.header("x-tenant-id", "acme")?
.connect()
.await?;
for tool in client.list_tools().await? {
println!("{}", tool.name);
}
client.close().await?;
# Ok(())
# }Security defaults worth knowing:
- Only absolute HTTP(S) endpoints. Plain
http://is rejected for non-local hosts unless you opt in withallow_insecure(true). - URLs with embedded credentials are rejected outright.
Debugoutput redacts the bearer token and only prints header names, never values.- Redirects are disabled and the SDK-created client has connect/request timeouts.
- SSE retry and automatic expired-session reinitialization are off by default.
- You can inject your own
reqwest::Clientfor proxy, TLS, DNS, or policy control.
The realtime feature is a typed bidirectional WebSocket client. Audio and video move as caller-owned byte slices and are encoded in memory.
use rustglm::{
RealtimeConfig, RealtimeRequest, TypedRealtimeSession,
};
# async fn run() -> rustglm::Result<()> {
let mut connection = RealtimeConfig::new("token").connect().await?;
let session = TypedRealtimeSession::default()
.instructions("Be concise.")
.server_vad();
connection
.send_request(&RealtimeRequest::session_update(session)?)
.await?;
connection
.send_request(&RealtimeRequest::append_audio(&[0_u8; 320])?)
.await?;
while let Some(event) = connection.next_typed_event().await {
let event = event?;
if let Some(text) = event.delta_text() {
print!("{text}");
}
}
# Ok(())
# }Transport hardening on this path:
- Default endpoint is
wss://. Plainws://is refused unless you explicitly setallow_insecure(true). - Message and frame size limits are enforced at the WebSocket layer.
- The event loop never blocks on a full outgoing channel — a slow consumer drops events instead of deadlocking the connection, pings, or close.
- Writes are time-bounded and
close()waits at most a few seconds for the background task.
The client also supports typed session tools, function-call output, response options, transcription sessions, client/server VAD, cancellation, audio commit/clear, video frames, and explicit close.
SdkError is the single public error type. Domain errors stay as typed enums, so you can match on them instead of parsing strings.
use rustglm::{BatchError, SdkError};
fn classify(error: SdkError) {
match error {
SdkError::Batch(BatchError::InvalidLimit(limit)) => {
eprintln!("invalid batch limit: {limit}");
}
SdkError::Api(api) => {
eprintln!("HTTP {} request_id={:?}", api.status, api.request_id);
}
SdkError::PayloadTooLarge { limit, .. } => {
eprintln!("response exceeded {limit} bytes");
}
other => eprintln!("{other}"),
}
}The envelope separates configuration, validation, transport, timeout, API, decode, stream, WebSocket, unsupported capability, agent, tool, Batch, RAG, and MCP failures. ApiError keeps the HTTP status, provider code, message, and request ID. It also keeps the raw response body — useful for debugging, but that body can contain model output or other sensitive data, so think twice before logging it verbatim.
HttpConfig controls request timeout, connect timeout, pool idle timeout, response size limit, user agent, default headers, retry policy, and an optional caller-built reqwest::Client.
- Retries are off by default; if enabled, only the status codes you configure are retried, and connection/timeout retries apply to idempotent methods only.
- Response bodies are read with a size cap (
max_response_bytes, 64 MiB default) so a misbehaving or malicious endpoint can't balloon your process memory. Error responses are read under a separate 64 KiB cap. The same idea applies to SSE events (16 MiB per event, at most 4096 data lines per event) and streamed tool arguments (1 MiB per call). - Base URLs must be HTTPS for non-local hosts. Plain
http://works for loopback and private ranges (local testing, LAN proxies) and elsewhere only afterallow_insecure(true). - JWT token caching uses a monotonic clock, so a backwards system-time jump can't extend the life of an expired token.
- Error bodies are scrubbed before they land in
ApiError: Zhipuid.secretkeys, bearer tokens, and the configured credential itself are replaced with[FILTERED]so secrets don't leak through your logs.
A quick index of the public surface. Methods that accept nextjson::Value exist on purpose — provider schemas move faster than this crate can ship typed builders.
| Area | Feature | Public operations |
|---|---|---|
| Chat and streams | core; tools for ToolStream |
chat_completion, chat_completion_stream, chat_tool_stream, typed_chat_completion, typed_chat_completion_stream, typed_chat_tool_stream |
| Async and vector APIs | core | async_chat, async_result, embedding, rerank, tokenizer |
| Images and video | images, video |
create_image, create_image_async, create_video |
| Audio and voice | audio |
glm_4_voice, transcribe, speech, clone_voice, voices, delete_voice |
| Hosted tools | tools |
web_search, read_web_page, moderate, moderate_content |
| Files and document processing | files |
upload_file, files, file_content, delete_file, create_file_parse_task, file_parse_result, parse_file_sync, ocr, parse_layout |
| Batch | batch |
create_batch, batches, batch, cancel_batch |
| Official agents and assistants | agents |
official_agent, official_agent_stream, official_agent_async_result, official_agent_conversation, assistant, assistants, assistant_conversations |
| Knowledge bases and retrieval | rag |
create_knowledge_base, knowledge_bases, knowledge_base, update_knowledge_base, delete_knowledge_base, knowledge_capacity, retrieve_knowledge, knowledge_documents, upload_knowledge_document, upload_knowledge_urls, knowledge_document, delete_knowledge_document, knowledge_document_images, reembed_knowledge_document, retrieval_agent_stream |
| Protocol escape hatch | core | request_json on both ZhipuClient and OpenAiCompatibleClient |
| Standalone MCP | mcp |
McpClientConfig::connect, plus typed tool, resource, prompt, and Streamable HTTP operations from rmcp |
| Realtime | realtime |
RealtimeConfig::connect, typed requests/events, VAD, media buffers, function-call output, cancellation, and explicit close |
request_json exists for provider fields and endpoints that aren't typed yet. It only takes relative paths on the configured base URL — absolute URLs and .. segments are rejected.
There are 36 runnable examples in examples/, one per endpoint family with a few lifecycle ones that string related operations together. cargo check --all-targets --all-features compiles all of them without touching the network.
| Example | What it shows |
|---|---|
chat_completion |
chat_completion |
chat_stream |
chat_completion_stream |
typed_chat |
typed_chat_completion, thinking, reasoning effort |
multimodal_chat |
vision content parts and image URL input |
function_calling |
function schemas and Tool::function |
tool_stream |
typed_chat_tool_stream and assembled function-call deltas |
async_chat |
async_chat, async_result |
embedding |
EmbeddingRequest, embedding |
rerank |
RerankRequest, rerank |
tokenizer |
TokenizerRequest, tokenizer |
openai_compatible |
OpenAiCompatibleConfig, ChatProvider |
| Example | What it shows |
|---|---|
image_generation |
create_image, create_image_async |
video_generation |
create_video, async task ID |
speech |
SpeechRequest, speech |
transcription |
TranscriptionRequest, transcribe |
glm_4_voice |
GLM-4-Voice input and WAV output |
voice_management |
clone_voice, voices, delete_voice |
file_management |
upload_file, files, file_content, delete_file |
file_parsing |
create_file_parse_task, file_parse_result, parse_file_sync |
document_understanding |
ocr, parse_layout |
| Example | What it shows |
|---|---|
web_search |
web_search |
hosted_tools |
read_web_page, moderate |
file_batch |
upload JSONL and create_batch |
batch_management |
create, list, retrieve, and cancel Batch operations |
knowledge_base |
create_knowledge_base |
knowledge_management |
knowledge-base list, detail, update, capacity, and delete |
knowledge_documents |
document list, upload, URL ingestion, detail, images, re-embed, and delete |
knowledge_retrieval |
retrieve_knowledge |
retrieval_agent |
retrieval_agent_stream |
| Example | What it shows |
|---|---|
official_agent |
typed official Agent v1 invocation |
official_agent_lifecycle |
Agent stream, async result, and conversation operations |
assistants |
Assistant invoke, list, and conversations |
custom_agent |
local agent runtime with an application tool |
interactive_chat |
multi-turn runtime and optional semantic memory |
mcp_client |
MCP tools, resources, prompts, and close |
realtime_audio_video |
Realtime PCM/WAV, optional JPEG frames, typed events |
Run one with cargo run --example <name> -- <args>. The MCP example needs the feature: cargo run --example mcp_client --features mcp -- <endpoint>. Most Zhipu examples want ZHIPU_API_KEY; openai_compatible uses OPENAI_COMPATIBLE_BASE_URL and OPENAI_COMPATIBLE_API_KEY. Running examples spends quota and can create or delete remote resources — don't point them at a production account casually.
CI runs formatting, Clippy with warnings denied, builds across no-default/default/all/individual enterprise feature sets, tests and doctests, docs with warnings denied, package construction from the committed lockfile, and enforces 90% line coverage on the all-feature build with LCOV plus text-summary artifacts.
The release workflow runs on v* tags. You can also trigger it manually from the Actions UI by selecting a commit or branch and entering v<Cargo.toml version> as the tag input — the workflow checks out that exact revision rather than assuming the tag exists. It rejects version mismatches, runs every release gate, builds the .crate, writes SHA256SUMS, creates the annotated tag if missing (an existing tag is only accepted if it points at the verified commit), then optionally publishes to crates.io when CARGO_REGISTRY_TOKEN is configured and creates or updates the GitHub Release.
# Bump Cargo.toml first, then:
git tag -s v1.0.0 -m "RustGLM v1.0.0"
git push origin v1.0.0No API key or registry token lives in the repo. CARGO_REGISTRY_TOKEN should only ever exist as a GitHub Actions secret.
cargo fmt --all -- --check
cargo test --all-targets --no-default-features
cargo test --all-targets
cargo test --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps
cargo package --lockedCoverage is enforced at 90% line coverage in CI, with LCOV and text-summary artifacts published per run. Reproduce it locally:
cargo coverage # summary plus the 90% line threshold
cargo coverage-lcov # target/rustglm-lcov.info plus the same thresholdFull per-module numbers live in docs/COVERAGE.md — treat the CI artifacts as authoritative for a given commit rather than a stale snapshot. The 2 live tests (live_zhipu, live_realtime) are ignored by default; run them deliberately with a real key:
$env:ZHIPU_API_KEY = "key_id.secret"
cargo test --test live_zhipu -- --ignored --nocapture
cargo test --test live_realtime -- --ignored --nocaptureApache License 2.0. See LICENSE.