Баг-репорт состоял из трёх слов: «Opus сломан». Он не был сломан. Сломалось предположение. У нас был один путь кода, который ставил thinking.type: "enabled" на каждую модель Claude, поддерживающую расширенные рассуждения. Anthropic выпустила Opus 4.7, которая отклоняет именно это поле с ошибкой 400. Она принимает только thinking.type: "adaptive". Одна модель, одно переименованное значение enum — и каждый запрос, направленный к ней, умирал на проводе.

В этом вся работа единого слоя LLM-провайдеров, сжатая в один инцидент. Вы пишете не HTTP-клиент. Вы поддерживаете живую модель того, как девятнадцать разных вендоров расходятся по одним и тем же четырём понятиям — сообщения, инструменты, токены и ошибки — и впитываете каждое их изменение, чтобы код над вами никогда не должен был об этом заботиться.

Это технический разбор octolib — open-source библиотеки на Rust, которая делает это впитывание для Octocode, Octomind и Octobrain. Я пройдусь по урокам, которые обошлись нам дороже всего, привязав каждый к реальному куску кода. Если вы читали введение в octolib или родственную статью про запуск одного AI-агента на множестве моделей, это и есть слой под обоими.


Форма абстракции

Контракт — это один trait. Всё, что под ним, — провайдер, который его реализует.

#[async_trait::async_trait]
pub trait AiProvider: Send + Sync {
    fn name(&self) -> &str;
    fn supports_model(&self, model: &str) -> bool;
    async fn chat_completion(&self, params: ChatCompletionParams) -> Result<ProviderResponse>;
    fn get_api_key(&self) -> Result<String>;
    // зонды возможностей со значениями по умолчанию: supports_caching, supports_vision,
    // supports_structured_output, supported_sampling_params, get_model_pricing...
}

Вызывающий код никогда не создаёт провайдера напрямую. Он передаёт строку provider:model фабрике:

let (provider, model) = ProviderFactory::get_provider_for_model("anthropic:claude-opus-4-8")?;
let response = provider.chat_completion(params).await?;

ProviderFactory::parse_model разбивает по первому двоеточию — префикс провайдера обязателен по дизайну. Никакого угадывания «провайдера по умолчанию». anthropic:claude-opus-4-8, deepseek:deepseek-chat, openrouter:anthropic/claude-opus-4.8, cli:claude/... — префикс это ключ маршрутизации, а имя модели это всё, что после него. Двадцать один провайдер регистрируется в одном match:

match provider_name.to_lowercase().as_str() {
    "openai"    => Ok(Box::new(OpenAiProvider::new())),
    "anthropic" => Ok(Box::new(AnthropicProvider::new())),
    "deepseek"  => Ok(Box::new(DeepSeekProvider::new())),
    "moonshot" | "kimi" => Ok(Box::new(MoonshotProvider::new())),
    "octohub"   => Ok(Box::new(OctoHubProvider::new())),
    // ... cerebras, groq, together, nvidia, cloudflare, fireworks,
    //     featherless, minimax, zai, byteplus, amazon, google,
    //     ollama, local, openrouter
    _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider_name)),
}

Методы trait по умолчанию — невоспетая половина всего этого. Провайдер, который не делает ничего особенного, наследует разумное поведение: supports_caching возвращает false, supported_sampling_params возвращает SamplingSupport::ALL, ценообразование откатывается к справочной таблице. Провайдер, который действительно особенный, переопределяет ровно те методы, где расходится, и ничего больше. В этом вся философия дизайна — сделать общий случай бесплатным, а странный случай локальным.

Урок нулевой, на котором держится всё остальное: абстракция — это не «API одной LLM». Это набор вопросов о возможностях, которые вам понадобится задать прежде, чем строить запрос. Мы не угадали этот список в первый день. Мы наращивали его каждый раз, когда провайдер нас удивлял, и trait — это рубцовая ткань.


Урок 1: Tool calls — это одна идея в трёх несовместимых формах

Если вы тщательно абстрагируете одну вещь, пусть это будет вызов инструментов. Именно здесь провайдеры расходятся сильнее всего и где тихое несоответствие даёт худший режим отказа: модель попросила вызвать инструмент, ваш код этого не заметил, агент завис.

Два вендора, одна функция, разный формат провода:

  • Anthropic возвращает блок контента tool_use. Аргументы живут в поле input, которое уже является JSON-объектом.
  • OpenAI и все OpenAI-образные возвращают массив tool_calls, где function.arguments это JSON-строка, которую вы должны распарсить сами — и которая, когда модель обрезает или галлюцинирует, не всегда является валидным JSON.

octolib моделирует это расхождение явно, а не делает вид, что его нет. Внутренний тип — это размеченный enum:

#[serde(tag = "provider")]
pub enum ProviderToolCalls {
    Anthropic  { content: Vec<AnthropicToolUse> },   // input: serde_json::Value
    OpenAI     { tool_calls: Vec<OpenAIToolCall> },   // function.arguments: String
    OpenRouter { tool_calls: Vec<OpenAIToolCall> },
    DeepSeek   { tool_calls: Vec<OpenAIToolCall> },
    Generic    { calls: Vec<GenericToolCall> },
}

extract_from_exchange диспетчеризует по имени провайдера, а to_tool_calls() схлопывает все пять вариантов в один плоский Vec<ToolCall> для вызывающего. Ветка OpenAI — там, где живёт урок:

let arguments: serde_json::Value = if call.function.arguments.is_empty() {
    serde_json::Value::Object(serde_json::Map::new())
} else {
    serde_json::from_str(&call.function.arguments)
        .map_err(ToolCallError::InvalidArguments)?
};

Пустая строка аргументов легальна и означает «нет аргументов» — это не ошибка. Битая — это настоящая ошибка. Нам пришлось различать эти два случая трудным путём, после того как модель выдала "" для инструмента без аргументов, а ранняя версия восприняла это как ошибку парсинга и отбросила вызов.

Есть и вторая, более тонкая ловушка: round-tripping. Когда вы отправляете разговор обратно для следующего хода, предыдущие tool calls ассистента нужно перекодировать в форму этого провайдера. Поэтому octolib хранит tool calls в одной канонической форме GenericToolCall в истории, а convert_messages каждого провайдера пересобирает его нативные блоки на выходе — Anthropic пересобирает блоки tool_use с нетронутым объектом; путь, совместимый с OpenAI, заново сериализует аргументы обратно в строку. Толерантный fallback тоже важен здесь:

serde_json::from_str(&call.function.arguments).unwrap_or_else(|_| {
    serde_json::json!({"raw_arguments": call.function.arguments})
})

Вместо того чтобы потерять битый tool call, мы сохраняем сырую строку под ключом raw_arguments. Модель иногда может оправиться, увидев свой собственный сломанный вывод. Она никогда не оправится от вызова, который тихо исчез.


Урок 2: Учёт токенов — там, где деньги тихо утекают

Каждый провайдер сообщает об использовании. Никакие двое не сообщают одинаково. Если вам нужна стоимость на запрос — а при тысячах запросов через дюжину провайдеров она нужна, — вам приходится нормализовать по-настоящему грязный вход в один struct:

pub struct TokenUsage {
    pub input_tokens: u64,        // ЧИСТЫЙ — никогда не включает токены кэша
    pub cache_read_tokens: u64,
    pub cache_write_tokens: u64,
    pub output_tokens: u64,
    pub reasoning_tokens: u64,
    pub total_tokens: u64,
    pub cost: Option<f64>,
    pub request_time_ms: Option<u64>,
}

Struct десериализации для OpenAI-совместимых провайдеров рассказывает настоящую историю. Один объект usage должен терпеть все диалекты сразу:

struct OpenAiCompatUsage {
    input_tokens: Option<u64>,       // некоторые провайдеры
    prompt_tokens: Option<u64>,      // классический OpenAI
    completion_tokens: Option<u64>,
    output_tokens: Option<u64>,      // другие
    total_tokens: Option<u64>,
    reasoning_tokens: Option<u64>,
    completion_tokens_details: Option<CompletionTokensDetails>,  // reasoning здесь...
    prompt_tokens_details: Option<PromptTokensDetails>,          // cached_tokens здесь
    total_cost: Option<f64>, cost: Option<f64>,                  // или здесь
    prompt_cost: Option<f64>, completion_cost: Option<f64>,      // ...или раскидано по этим
}

Извлечение — это цепочка fallback'ов через .or(): input это input_tokens или prompt_tokens или prompt_eval_count от Ollama или ноль. Токены рассуждений прячутся в поле верхнего уровня или вложены под completion_tokens_details. Стоимость это total_cost или cost или prompt_cost + completion_cost, и только если конфиг провайдера решает доверять стоимости от upstream. Когда API не возвращает поле стоимости, octolib вычисляет её из таблицы цен по модели.

Две вещи укусили нас именно здесь, обе теперь закодированы в комментариях в коде, потому что мы никогда не хотим переучивать их:

  • input_tokens должен быть «чистым». Некоторые API складывают кэшированные токены в счёт входа; некоторые нет. Мы сами вычитаем cache_read_tokens, чтобы поле всегда значило одно и то же, иначе математика стоимости дважды считает дешёвые кэшированные токены по дорогой ставке.
  • Разбиение TTL кэша у Anthropic. Anthropic тарифицирует 5-минутные записи в кэш по 1.25× от входа, а часовые — по 2×. Она возвращает разбивку по TTL в cache_creationно только иногда. Когда этот вложенный объект отсутствует, безопасное предположение — 5m, а не 1h. Угадайте 1h — и переплатите в собственном учёте стоимости примерно на 60% при самом частом использовании. Это однострочное значение по умолчанию с четырёхстрочным комментарием, объясняющим почему.

Скучный урок: отслеживание стоимости — не функция, которую прикручивают сверху. Это задача нормализации, переодетая в арифметику, и арифметика — лёгкая часть.


Урок 3: Мы не стримим, и это было правильным решением

Этот идёт против интуиции, поэтому позвольте быть точным. Среди провайдеров, которые принимают флаг stream, octolib отправляет stream: false. Комментарий в адаптере DeepSeek прямолинеен: // We don't support streaming in octolib yet. Мы буферизуем весь ответ перед возвратом.

Для библиотеки, чья работа — циклы агента и структурированный вывод, это фича, а не пробел. Причина — в общем HTTP-хелпере:

pub(super) async fn send_and_read(
    request: reqwest::RequestBuilder,
    timeout: Option<Duration>,
) -> anyhow::Result<CapturedResponse> {
    let response = apply_request_timeout(request, timeout).send().await?;
    let status = response.status();
    let headers = response.headers().clone();
    let body = response.text().await?;   // тело читается ВНУТРИ повторяемой единицы
    Ok(CapturedResponse { status, headers, body })
}

И send(), и полное чтение тела происходят внутри одной и той же асинхронной единицы, которую оборачивает цикл повторов. Это важно, потому что самый мерзкий сбой транспорта — это RST посреди потока: соединение умирает после того, как пришли заголовки, но пока тело ещё идёт. Файрвол, инспектирующий полезную нагрузку, перегруженный балансировщик, таймаут NAT на долгой генерации. Если вы стримите, этот сбой всплывает глубоко в вашем обработчике токенов, с уже выданной половиной ответа, без чистого способа повторить. Поскольку мы читаем тело внутри повторяемой единицы, is_connection_error классифицирует сбой (is_connect() || is_request() || is_body()), цикл обновляет HTTP-клиент, и весь запрос повторяется чисто.

Компромисс честный: вы не получаете задержку первого токена и не можете рендерить вывод постепенно. Для чат-UI — стримьте. Для агента, который всё равно собирается распарсить весь ответ в tool calls или JSON-схему прежде, чем что-либо делать, буферизация проще, корректнее на ненадёжных сетях и убирает целый класс багов частичного состояния. Мы выбрали корректность. Стриминг в списке; он никогда не был срочным.


Урок 4: Повторы — это легко. Знать, что повторяемо, — нет.

Сам backoff ничем не примечателен — base_timeout * 2^attempt, ограниченный пятью минутами, отменяемый через токен watch:

pub fn is_retryable_status(status: u16) -> bool {
    status == 429 || status >= 500
}

Повторяйте 429 и 5xx. Никогда не повторяйте 4xx — плохой запрос остаётся плохим. Эта часть — двенадцать строк.

Трудная часть — путь ошибки соединения. Соединение HTTP из пула, которое upstream тихо полузакрыл, даёт ошибку reqwest, у которой is_request() = true, is_connect() = false. Если вы проверяете только is_connect(), вы никогда не обновляете пул, и каждый повтор переиспользует тот же мёртвый сокет — так что все ваши повторы падают одинаково, и вы заключаете, что провайдер лежит, хотя это не так. Мы узнали это от эндпоинтов, размещённых в CN (DeepSeek, Moonshot), которые агрессивно закрывают неактивные keep-alive соединения. Исправление — трактовать is_connect(), is_request() и is_body() все как ошибки соединения и атомарно подменять свежий клиент перед следующей попыткой:

if on_connection_error(&e) {
    crate::llm::providers::shared::refresh_http_client();  // новый пул, без старых сокетов
}

Сам клиент настроен ровно под это: таймаут соединения 20с (ограничивающий рукопожатие, а не генерацию, которая может занимать минуты), TCP keepalive, PING-фреймы HTTP/2 и таймаут простоя пула 30с, чтобы выселять соединения прежде, чем их убьёт upstream edge. Ничто из этого не гламурно. Всё это — разница между библиотекой, которая переживает вечер плохой сети, и той, что будит вас алертом.


Урок 5: Причуды по моделям живут в данных, а не в if, разбросанных повсюду

Назад к стартовому инциденту. Исправление было не хаком — это было признание, что «какие ручки сэмплинга и рассуждений принимает эта модель» — это данные о моделях, и помещение их туда, куда идут данные. В адаптере Anthropic это горстка срезов const:

const NO_SAMPLING_MODELS: &[&str]     = &["fable-5", "mythos-5", "opus-4-8", "opus-4-7"];
const ADAPTIVE_ONLY_MODELS: &[&str]   = &["fable-5", "mythos-5", "opus-4-7"];   // ручной thinking → 400
const NO_TOP_P_MODELS: &[&str]        = &["opus-4-1", "opus-4-7", "sonnet-4-5", /* ... */];
const EFFORT_PARAM_MODELS: &[&str]    = &["fable-5", "mythos-5", "opus-4-7", "opus-4-6", /* ... */];

Общий механизм, делающий это компонуемым, — SamplingSupport: крошечная булева маска, которую провайдер возвращает на модель, объявляя, какие из temperature, top_p, top_k модель вообще примет:

pub struct SamplingSupport { pub temperature: bool, pub top_p: bool, pub top_k: bool }
// SamplingSupport::ALL, ::NONE, ::TEMPERATURE_ONLY, ::TEMPERATURE_AND_TOP_P

Trait сливает запрошенные пользователем значения с этой маской в effective_sampling_params. У модели, которая отклоняет top_p, просто никогда нет top_p в теле запроса — никакой вызывающий код нигде не пишет if model == .... Есть связанная тонкость, которую мы нашли только получив 400: некоторые модели Anthropic отклоняют любое недефолтное значение сэмплинга, как только включены рассуждения, поэтому адаптер заранее решает thinking_enabled и зажимает сэмплинг соответственно, прежде чем строить запрос. Урок обобщается за пределы Anthropic: в момент, когда у вас две проверки if model.contains(...) для одного концепта, у вас рождается таблица данных. Дайте ей родиться.


Где вписывается OctoHub: прокси поверх слоя

Естественный вопрос, когда у вас есть единый клиент: что если бы унификация жила в сети, чтобы не-Rust клиенты тоже её получали? Это OctoHub — высокопроизводительный прокси-сервер LLM, который буквально является octolib с HTTP-лицом и базой данных.

Прокси-движок OctoHub не реализует заново ни одного провайдера. Он зависит от octolib и вызывает ту же фабрику:

let (provider_name, model) = self.config.resolve_model(&req.model)?;
let provider = ProviderFactory::create_provider(&provider_name)?;
let response = provider.chat_completion(params).await?;

Что OctoHub добавляет — это операционный слой вокруг этого вызова: API-ключи на тенанта, полное логирование запроса/ответа в SQLite/MySQL/Postgres, аналитика использования, маппинг имён моделей (короткий алиас разрешается в список строк provider:model, одна выбирается случайно для грубой балансировки нагрузки) и лимиты конкурентности на провайдера с таймаутами upstream. Он говорит на форме Responses-API (/v1/completions, previous_completion_id для мультихода) и на OpenAI-совместимом /v1/chat/completions для клиентов, которые уже говорят на нём.

И затем он замыкает петлю: octolib также поставляет провайдер octohub:. Так что Rust-приложение, использующее octolib, может маршрутизироваться через инстанс OctoHub — который маршрутизирует обратно наружу через octolib к настоящему upstream — получая централизованные ключи, логирование и учёт стоимости бесплатно, с той же строкой provider:model везде. Провайдер octohub объявляет каждую возможность поддерживаемой (supports_caching, supports_vision, supports_structured_output все true) именно потому, что не может знать, что за прокси; настоящий upstream возвращает явную ошибку, если базовая модель не может выполнить запрос. Прокси намеренно честен в своём собственном незнании.

В этом окупаемость правильно проведённой границы trait. Одна и та же абстракция обслуживает и внутрипроцессный вызов библиотеки, и сетевой хоп, и они компонуются, при этом ни одна сторона не знает, с какой из них она разговаривает.


Что бы я сказал тому, кто начинает это сегодня

Урок Конкретика
Абстрагируйте возможности, а не эндпоинты Trait — это список вопросов (supports_caching, supported_sampling_params), а не только chat_completion
Моделируйте расхождение tool calls явно Размеченный enum на форму провайдера лучше одного «универсального» struct с потерями
Нормализуйте использование, прежде чем доверять стоимости Цепочки .or() между prompt_tokens/input_tokens/prompt_eval_count; держите input_tokens чистым от кэша
Буферизуйте, если всё равно парсите Чтение тела внутри повторяемой единицы делает RST посреди потока повторяемыми
Обновляйте пул при ошибках соединения is_request() и is_body() тоже ошибки соединения, не только is_connect()
Причуды — это данные Срезы const моделей и маска SamplingSupport, никогда разбросанные if model.contains

Ничто из этого не ново. Всё это — из того рода вещей, которые записываешь только после того, как они тебя укусили, поэтому большинство этих уроков живут как четырёхстрочные комментарии над однострочными значениями по умолчанию в реальном коде. Самая трудная часть единого слоя LLM-провайдеров — не абстракция. Это оставаться честным насчёт того, насколько сильно на самом деле различаются вещи под ним, — и отказаться замазывать реальную разницу обнадёживающим значением по умолчанию.

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

octolib — это библиотека на Rust для LLM, которую я могу использовать самостоятельно?

Да. Она open-source под Apache-2.0, компилируется через cargo check, а примеры запускаются всего лишь с API-ключом. Строка provider:model — это весь интерфейс. Она делает LLM-инференс, эмбеддинги и реранкинг — больше ничего, ни агентов, ни цепочек.

Какие провайдеры поддерживает единый слой?

Двадцать один LLM-провайдер регистрируется в фабрике, включая OpenAI, Anthropic, Google, DeepSeek, Moonshot, MiniMax, Z.ai, Groq, Cerebras, Together, NVIDIA, Cloudflare, Fireworks, Featherless, BytePlus, Amazon Bedrock, OpenRouter, OctoHub, Ollama, локальные эндпоинты и backend cli:. Нативные провайдеры получают специальные адаптеры; остальные делят OpenAI-совместимый путь.

Стримит ли он tool calls?

Сегодня нет — octolib буферизует полный ответ (stream: false), чтобы сбросы соединения посреди потока становились чисто повторяемыми, что важнее для циклов агента и структурированного вывода, чем задержка первого токена. Стриминг в дорожной карте.

Чем OctoHub отличается от octolib?

octolib — это внутрипроцессная библиотека на Rust. OctoHub — это прокси-сервер, построенный поверх неё: та же маршрутизация провайдеров плюс мультитенантные ключи, логирование запросов, аналитика использования и балансировка нагрузки. octolib даже может вызывать OctoHub через свой провайдер octohub:.

— Don


octolib и OctoHub — open-source под Apache-2.0. Если нужного вам провайдера нет, шаблон лежит в src/llm/providers/openai.rsоткройте issue или PR.