Rust и async-openai: подключаем Claude через OpenAI-совместимый API

Как подключить Claude к Rust-приложению: async-openai с with_api_base, флаг chat-completion, стриминг, ограничение параллельности и обработка ошибок. Вариант на reqwest для минимума зависимостей.

4 мин · · разработка

Для Rust нет нужды ждать отдельного SDK под каждый API: Zukko принимает запросы в OpenAI-формате по адресу https://api.zukko.pro/v1, поэтому Claude подключается через крейт async-openai или напрямую через reqwest. Ниже — оба варианта на актуальной версии async-openai 0.42, стриминг с правильной обработкой последнего чанка, параллельные запросы с ограничением и разбор ошибок.

Зависимости

bash
[package]
name = "zukko-rust"
version = "0.1.0"
edition = "2021"

[dependencies]
async-openai = { version = "0.42", features = ["chat-completion"] }
futures = "0.3"
reqwest = { version = "0.13", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Главная ловушка новых версий async-openai: API разбиты на feature-флаги, и по умолчанию включён только TLS. Без features = ["chat-completion"] у клиента не будет метода chat(), и код из старых примеров не скомпилируется. Типы чата теперь лежат в модуле async_openai::types::chat. Список флагов — на странице крейта на docs.rs.

reqwest и serde нужны только для первого варианта; если используете лишь async-openai, их можно убрать.

Ключ fetch_... создайте в кабинете Zukko и положите в переменную окружения: export ZUKKO_API_KEY=fetch_....

Вариант 1: reqwest и serde

Минимум абстракций: обычный POST и разбор JSON в свои структуры.

bash
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct ChatResponse {
    choices: Vec<Choice>,
    usage: Option<Usage>,
}

#[derive(Deserialize)]
struct Choice {
    message: Message,
}

content объявлен как Option<String>: в OpenAI-формате поле может прийти null (например, если модель вернула вызов инструмента), и String без Option уронил бы десериализацию.

Вариант 2: async-openai с Zukko

Адрес API переопределяется методом with_api_base у OpenAIConfig, ключ — with_api_key. Запрос собирается билдером CreateChatCompletionRequestArgs, как в официальных примерах крейта.

bash
use async_openai::{
    config::OpenAIConfig,
    types::chat::{
        ChatCompletionRequestSystemMessage, ChatCompletionRequestUserMessage,
        CreateChatCompletionRequestArgs,
    },
    Client,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = OpenAIConfig::new()
        .with_api_base("https://api.zukko.pro/v1")
        .with_api_key(std::env::var("ZUKKO_API_KEY")?);

Ответ модели лежит в choice.message.content типа Option<String>. В старых версиях статей встречается разбор ответа через ChatCompletionRequestMessage::Assistant — это тип запроса, а не ответа, такой код не компилируется.

Стриминг ответа

create_stream возвращает поток Result<CreateChatCompletionStreamResponse, OpenAIError>. Последний чанк от Zukko содержит только usage, а массив choices в нём пустой. Поэтому берём choices.first() через if let, а не индексом [0], который на этом чанке вызвал бы панику.

bash
use std::io::Write;

use async_openai::{
    config::OpenAIConfig,
    types::chat::{ChatCompletionRequestUserMessage, CreateChatCompletionRequestArgs},
    Client,
};
use futures::StreamExt;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = OpenAIConfig::new()
        .with_api_base("https://api.zukko.pro/v1")
        .with_api_key(std::env::var("ZUKKO_API_KEY")?);

flush() нужен, потому что stdout буферизуется построчно, и без него текст появлялся бы рывками.

Параллельные запросы с ограничением

Client клонируется дёшево, поэтому его можно отдавать в задачи. Запускать сотню запросов одновременно не стоит — получите 429. buffer_unordered(3) держит не больше трёх запросов в полёте:

bash
use async_openai::{
    config::OpenAIConfig,
    error::OpenAIError,
    types::chat::{ChatCompletionRequestUserMessage, CreateChatCompletionRequestArgs},
    Client,
};
use futures::StreamExt;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = OpenAIConfig::new()
        .with_api_base("https://api.zukko.pro/v1")
        .with_api_key(std::env::var("ZUKKO_API_KEY")?);
    let client = Client::with_config(config);

Результаты приходят в порядке завершения, а не в порядке вопросов, поэтому вопрос возвращается вместе с ответом.

Обработка ошибок

Ошибки API приходят как OpenAIError::ApiError(ApiErrorResponse): внутри status_code (HTTP-статус) и api_error с полем message. Сетевые проблемы — OpenAIError::Reqwest, ответ не в ожидаемом формате — OpenAIError::JSONDeserialize вместе с исходным текстом.

bash
match client.chat().create(request).await {
    Ok(response) => {
        if let Some(choice) = response.choices.first() {
            println!("{}", choice.message.content.as_deref().unwrap_or(""));
        }
    }
    Err(OpenAIError::ApiError(e)) => match e.status_code.as_u16() {
        401 => eprintln!("Неверный ключ"),
        402 => eprintln!("Не хватает баланса"),
        403 => eprintln!("Модель не разрешена для этого ключа"),
        429 => eprintln!("Слишком частые запросы, повторите позже"),
        503 => eprintln!("Провайдер недоступен: {}", e.api_error.message),
        code => eprintln!("Ошибка API {code}: {}", e.api_error.message),
    },
    Err(e) => eprintln!("Ошибка: {e}"),
}

Для 429 и 503 разумно повторять запрос с паузой, для 401–403 — нет: повтор ничего не изменит.

Модели и стоимость

Модель задаётся строкой в .model(...). Цены в Zukko за 1M токенов (вход/выход): claude-sonnet-5 и claude-opus-5-5 — $0.30 / $0.30, gpt-5.6-sol — $0.30 / $0.30, gpt-5.6-luna — $0.10 / $0.10, glm-5.3-flash — $0.04 / $0.04. Актуальный список — GET https://api.zukko.pro/v1/models или страница «Модели» в кабинете.

Пример: CLI-утилита делает 5 000 запросов в месяц по 1 500 входных и 500 выходных токенов. На claude-sonnet-5: 5 000 × (1 500 + 500) × $0.30 / 1 000 000 = $3.00. На glm-5.3-flash: 5 000 × 2 000 × $0.04 / 1 000 000 = $0.40. Какая модель справляется с вашей задачей, проверьте на своих данных: прогоните одинаковый набор запросов через две модели и сравните результаты.

Частые вопросы

Ошибка «no method named chat found»

Не включён feature-флаг. Добавьте features = ["chat-completion"] в зависимость async-openai. Если нужны все API крейта, есть флаг full, но он заметно увеличивает время сборки.

Получаю 404 Not Found

Проверьте with_api_base: адрес должен заканчиваться на /v1 — https://api.zukko.pro/v1. Крейт сам добавляет /chat/completions.

Можно ли передать параметры, которых нет в типах крейта?

Да. С флагом byot у методов появляются варианты с суффиксом _byot, например client.chat().create_byot(json!({...})), — они принимают произвольный JSON. Это описано в документации крейта в разделе «Bring Your Own Types».

Как вести диалог с историей?

Храните Vec<ChatCompletionRequestMessage>, добавляйте в него реплики пользователя и ответы модели (ChatCompletionRequestAssistantMessage::from(...)) и передавайте весь вектор в .messages(...). API ничего не помнит между запросами, а история оплачивается как входные токены, поэтому старые реплики обрезайте. Подробнее о формате запроса — в справочнике chat completions.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.