Чтобы вызвать языковую модель из кода, установите официальный SDK (@anthropic-ai/sdk или openai), положите ключ в переменную окружения на сервере и отправьте запрос: у Anthropic это client.messages.create с полями model, max_tokens, system и messages, у OpenAI — client.responses.create с model, instructions и input. Для интерфейсов включают стриминг, а ошибки 429 и 5xx SDK по умолчанию повторяет сам; ваша задача — задать лимиты, таймаут и разобрать оставшиеся ошибки по типам.
После материала вы сможете
- отправить запрос к Anthropic Messages API и OpenAI Responses API через официальные SDK, взяв ключ из переменной окружения
- передать системную инструкцию и историю диалога и извлечь текст ответа без привязки к позиции блока
- включить стриминг у обоих провайдеров и обработать ошибку, пришедшую посреди потока
- настроить
maxRetriesиtimeout, отличить временную ошибку 429 от исчерпанной квоты и посчитать расход поusage
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)TypeScript 7.0 (tsc 7.0.2)@anthropic-ai/sdk 0.133.0openai (npm) 7.32.0@types/node 24claude-opus-5-5, gpt-6-astra (2026-10-12)
Сверено с документацией: 12 октября 2026 г.
Содержание
Что понадобится и где хранить ключ
Урок рассчитан на разработчика, который уже пишет на TypeScript и работает с async/await. Понадобятся Node.js 24, аккаунты в консоли Anthropic и в платформе OpenAI и по одному API-ключу. Ключ — это пароль от вашего счёта: любой, кто его получил, тратит ваши деньги. Поэтому правило одно: ключ живёт только на сервере, в переменной окружения, и никогда не попадает в браузер, мобильное приложение или репозиторий.
mkdir llm-first-request
cd llm-first-request
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk openai
npm install -D typescript @types/node@24
mkdir src{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}# Copy to .env and put your own keys there. Never commit .env.
ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
OPENAI_API_KEY=YOUR_OPENAI_API_KEYПервый запрос к Claude: Messages API
У Anthropic всё идёт через один эндпоинт — Messages API. Минимальный запрос содержит идентификатор модели, лимит длины ответа max_tokens и массив messages; системная инструкция передаётся отдельным полем system. Идентификатор модели берите со страницы моделей: на 12.10.2026 документация советует начинать с claude-opus-5-5, а для массовых простых задач есть более быстрая и дешёвая claude-haiku-5-5.
import Anthropic from "@anthropic-ai/sdk";
// The SDK reads ANTHROPIC_API_KEY from the environment; fail fast with a clear message.
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error("ANTHROPIC_API_KEY is not set: copy .env.example to .env");
}
const client = new Anthropic();
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 2048,
system: "You are a concise assistant for a web studio. Answer in two sentences.",
messages: [{ role: "user", content: "What is an HTTP 429 status code?" }],
});
// content is an array of blocks (text, thinking, tool_use...): take only text blocks.
const text = message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
console.log(text);
console.log("stop_reason:", message.stop_reason);
console.log("tokens in/out:", message.usage.input_tokens, message.usage.output_tokens);npx tsc -p .
node --env-file=.env src/anthropic-hello.ts
node --env-file=.env src/openai-hello.tscontent— массив блоков, а не строка. У текущих моделей Claude рассуждение включено всегда, поэтому перед текстом может прийти блокthinking; братьcontent[0].textнельзя — фильтруйте поtype === "text"max_tokens— жёсткий потолок ответа, и рассуждения модели тоже в него входят. Если пришёлstop_reason: "max_tokens", ответ обрезанstop_reason: "end_turn"— модель закончила сама;"refusal"— отказалась выполнять запрос, такой ответ нельзя показывать как обычныйusage.input_tokensиusage.output_tokens— фактический расход, по нему считают стоимость
Первый запрос к OpenAI: Responses API
У OpenAI два текстовых API: старый Chat Completions и Responses. Документация OpenAI рекомендует для новых приложений Responses API, а с моделями-рассуждателями — тем более. Запрос проще: input может быть строкой, системная инструкция — полем instructions, а готовый текст SDK собирает в response.output_text. На 12.10.2026 в документации OpenAI рекомендованы gpt-6-astra для сложных задач, gpt-6.1-sol как баланс цены и качества и gpt-6-luna для массовых задач.
import OpenAI from "openai";
// The SDK reads OPENAI_API_KEY from the environment; fail fast with a clear message.
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is not set: copy .env.example to .env");
}
const client = new OpenAI();
const response = await client.responses.create({
model: "gpt-6-astra",
reasoning: { effort: "low" },
instructions: "You are a concise assistant for a web studio. Answer in two sentences.",
input: "What is an HTTP 429 status code?",
});
// output_text joins every text item from response.output into one string.
console.log(response.output_text);
console.log("status:", response.status);
console.log("tokens in/out:", response.usage?.input_tokens, response.usage?.output_tokens);Роли сообщений и системная инструкция
LLM API не хранит диалог: каждый запрос несёт всю историю, которую модель должна учесть. История — это список сообщений с ролями user (пользователь) и assistant (прежние ответы модели). Системная инструкция задаёт роль, правила и формат для всего разговора и важнее отдельных сообщений. У Anthropic это поле system, у OpenAI — instructions (или сообщение с ролью developer в input).
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
type Turn = { role: "user" | "assistant"; content: string };
const system = "You are a support assistant of a coffee shop. Answer briefly.";
// The API is stateless: every request carries the whole conversation.
const history: Turn[] = [
{ role: "user", content: "Do you have oat milk?" },
{ role: "assistant", content: "Yes, oat and almond milk are available." },
{ role: "user", content: "Is it extra?" },
];
const anthropic = new Anthropic();
const claude = await anthropic.messages.create({
model: "claude-opus-5-5",
max_tokens: 2048,
system, // Anthropic: the system prompt is a separate top-level field
messages: history,
});
const claudeText = claude.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
console.log("Claude:", claudeText);
const openai = new OpenAI();
const gpt = await openai.responses.create({
model: "gpt-6-astra",
instructions: system, // OpenAI: instructions outrank messages in input
input: history,
});
console.log("GPT:", gpt.output_text);Системная инструкция
- Anthropic Messages API
system- OpenAI Responses API
instructionsили рольdeveloper
История диалога
- Anthropic Messages API
messages:user/assistant- OpenAI Responses API
input: строка или массив сообщений
Лимит ответа
- Anthropic Messages API
max_tokens(обязателен)- OpenAI Responses API
max_output_tokens(необязателен)
Текст ответа
- Anthropic Messages API
- блоки
contentсtype: "text" - OpenAI Responses API
output_text
Почему остановилась
- Anthropic Messages API
stop_reason- OpenAI Responses API
statusиincomplete_details
Расход
- Anthropic Messages API
usage.input_tokens/output_tokens- OpenAI Responses API
usage.input_tokens/output_tokens
Стриминг ответа
Без стриминга пользователь ждёт, пока модель сгенерирует весь ответ. Со стримингом (server-sent events, SSE — поток событий по одному HTTP-ответу) текст появляется по мере генерации. В Anthropic SDK удобнее всего помощник messages.stream(): событие text отдаёт куски текста, а finalMessage() возвращает собранное сообщение с stop_reason и usage. В OpenAI SDK запрос с stream: true возвращает асинхронный поток типизированных событий: текст приходит в response.output_text.delta, конец — в response.completed.
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
const question = "List three checks before deploying a website.";
// Anthropic: the stream helper emits text chunks and assembles the final message.
const anthropic = new Anthropic();
const stream = anthropic.messages
.stream({
model: "claude-opus-5-5",
max_tokens: 2048,
messages: [{ role: "user", content: question }],
})
.on("text", (chunk) => process.stdout.write(chunk));
const final = await stream.finalMessage();
console.log("\n[claude] stop_reason:", final.stop_reason, "output tokens:", final.usage.output_tokens);
// OpenAI: stream: true returns an async iterable of typed events.
const openai = new OpenAI();
const events = await openai.responses.create({
model: "gpt-6-astra",
input: question,
stream: true,
});
for await (const event of events) {
if (event.type === "response.output_text.delta") {
process.stdout.write(event.delta);
} else if (event.type === "response.completed") {
console.log("\n[gpt] status:", event.response.status, "output tokens:", event.response.usage?.output_tokens);
} else if (event.type === "error") {
console.error("\n[gpt] stream error:", event.message);
}
}Ошибки, лимиты и повторы
Оба SDK бросают типизированные ошибки — наследников APIError с полем status: AuthenticationError (401), RateLimitError (429), InternalServerError (5xx), APIConnectionError и APIConnectionTimeoutError для сети и таймаута. Временные сбои — сетевые ошибки, 408, 409, 429 и 5xx — SDK по умолчанию повторяет сам два раза с экспоненциальной задержкой, учитывая заголовок retry-after. Число повторов задаёт maxRetries, таймаут — timeout в миллисекундах (по умолчанию 10 минут).
400
- Что случилось
- Неверный запрос: параметр, формат, неподдерживаемое значение
- Что делать
- Не повторять, исправить запрос; залогировать
requestID
401 / 403
- Что случилось
- Неверный, отозванный ключ или нет прав
- Что делать
- Не повторять, проверить ключ и доступ
429 с retry-after
- Что случилось
- Превышен лимит запросов или токенов в минуту
- Что делать
- Подождать указанное время, снизить темп или поставить в очередь
429 без retry-after или с кодом квоты
- Что случилось
- Исчерпан бюджет, кредит или лимит расходов
- Что делать
- Повтор не поможет: пополнить баланс или поднять лимит
500, 503, 529
- Что случилось
- Сбой или перегрузка на стороне провайдера
- Что делать
- Повтор с паузой; SDK делает это сам
Сеть или таймаут
- Что случилось
- Нет ответа за отведённое время
- Что делать
- Повтор; для длинных ответов — стриминг
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
export type Provider = "anthropic" | "openai";
export type AskInput = {
system: string;
prompt: string;
maxOutputTokens?: number;
};
export type AskResult = {
provider: Provider;
model: string;
text: string;
inputTokens: number;
outputTokens: number;
complete: boolean; // false when the answer was cut off by the token limit
};
export class LLMError extends Error {
readonly provider: Provider;
readonly status: number | undefined;
readonly retryable: boolean; // temporary problem: try later or put the job in a queue
constructor(provider: Provider, message: string, options: { status?: number; retryable: boolean; cause?: unknown }) {
super(message, { cause: options.cause });
this.name = "LLMError";
this.provider = provider;
this.status = options.status;
this.retryable = options.retryable;
}
}
// Model IDs: copy them from the providers' model pages, do not guess.
const MODELS = { anthropic: "claude-opus-5-5", openai: "gpt-6-astra" } as const;
// SDK-level retries with exponential backoff (429, 408, 409, 5xx, network) and a request timeout.
const CLIENT_OPTIONS = { maxRetries: 3, timeout: 60_000 };
function requireKey(name: "ANTHROPIC_API_KEY" | "OPENAI_API_KEY"): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set: copy .env.example to .env`);
return value;
}
// Clients are created on first use, so a missing key of one provider does not break the other.
let anthropic: Anthropic | undefined;
let openai: OpenAI | undefined;
async function askAnthropic(input: AskInput): Promise<AskResult> {
anthropic ??= new Anthropic({ apiKey: requireKey("ANTHROPIC_API_KEY"), ...CLIENT_OPTIONS });
const message = await anthropic.messages.create({
model: MODELS.anthropic,
max_tokens: input.maxOutputTokens ?? 2048,
system: input.system,
messages: [{ role: "user", content: input.prompt }],
});
if (message.stop_reason === "refusal") {
throw new LLMError("anthropic", "anthropic: the model declined the request", { retryable: false });
}
return {
provider: "anthropic",
model: message.model,
text: message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join(""),
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
complete: message.stop_reason !== "max_tokens",
};
}
async function askOpenAI(input: AskInput): Promise<AskResult> {
openai ??= new OpenAI({ apiKey: requireKey("OPENAI_API_KEY"), ...CLIENT_OPTIONS });
const response = await openai.responses.create({
model: MODELS.openai,
reasoning: { effort: "low" },
instructions: input.system,
input: input.prompt,
max_output_tokens: input.maxOutputTokens ?? 2048,
});
return {
provider: "openai",
model: response.model,
text: response.output_text,
inputTokens: response.usage?.input_tokens ?? 0,
outputTokens: response.usage?.output_tokens ?? 0,
complete: response.status === "completed",
};
}
const QUOTA_CODES = new Set([
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
]);
// Both SDKs throw the same family of typed errors: check specific classes first.
function toLLMError(provider: Provider, err: unknown): unknown {
if (err instanceof Anthropic.AuthenticationError || err instanceof OpenAI.AuthenticationError) {
return new LLMError(provider, `${provider}: invalid or revoked API key`, { status: 401, retryable: false, cause: err });
}
if (err instanceof Anthropic.RateLimitError || err instanceof OpenAI.RateLimitError) {
const wait = err.headers.get("retry-after");
// A spend or quota limit is not temporary: Anthropic sends that 429 without retry-after,
// OpenAI marks it with a code such as credit_balance_exhausted.
const quota = err instanceof OpenAI.RateLimitError ? QUOTA_CODES.has(err.code ?? "") : wait === null;
const message = quota ? `${provider}: spend or quota limit reached` : `${provider}: rate limit, retry after ${wait ?? "?"} s`;
return new LLMError(provider, message, { status: 429, retryable: !quota, cause: err });
}
if (err instanceof Anthropic.APIConnectionError || err instanceof OpenAI.APIConnectionError) {
return new LLMError(provider, `${provider}: network error or timeout`, { retryable: true, cause: err });
}
if (err instanceof Anthropic.APIError || err instanceof OpenAI.APIError) {
const status = err.status;
return new LLMError(provider, `${provider}: HTTP ${status}, request ${err.requestID ?? "-"}`, {
status,
retryable: status !== undefined && status >= 500,
cause: err,
});
}
return err;
}
export async function ask(provider: Provider, input: AskInput): Promise<AskResult> {
try {
return provider === "anthropic" ? await askAnthropic(input) : await askOpenAI(input);
} catch (err) {
throw toLLMError(provider, err);
}
}- Повторы с паузами уже встроены в SDK: свой цикл повторов поверх них умножит число запросов, как и повторы на нескольких уровнях системы
retryableвLLMErrorподсказывает вызывающему коду, можно ли поставить задачу в очередь на потом или нужно сразу сообщить об ошибке- Лимиты у Anthropic считаются в запросах и входных и выходных токенах в минуту, у OpenAI — в запросах и токенах в минуту и в сутки; текущие значения видны в консоли и в заголовках ответа (
anthropic-ratelimit-*,x-ratelimit-*) - Для разбора с поддержкой логируйте идентификатор запроса:
requestIDу ошибки,_request_idу ответа Anthropic
Общий интерфейс, учёт токенов и проверка
Скрипт ниже вызывает обоих провайдеров через одну функцию ask и печатает ответ и расход. Стоимость считают по usage и ценам со страниц цен; цены меняются, поэтому их держат в конфигурации, а не в коде. Учтите, что рассуждения моделей оплачиваются как выходные токены, а история диалога при каждом запросе — снова как входные.
import { ask, LLMError, type Provider } from "./llm.ts";
const providers: Provider[] = ["anthropic", "openai"];
const input = {
system: "You are a concise assistant for a web studio. Answer in two sentences.",
prompt: "Why should an API key never be stored in frontend code?",
};
for (const provider of providers) {
try {
const result = await ask(provider, input);
console.log(`[${result.provider}] ${result.model}: ${result.text}`);
console.log(` tokens in/out: ${result.inputTokens}/${result.outputTokens}${result.complete ? "" : " (cut off)"}`);
} catch (err) {
if (!(err instanceof LLMError)) throw err;
console.error(`[${provider}] ${err.message} (retryable: ${err.retryable})`);
process.exitCode = 1;
}
}// Prices change: take them from the provider's pricing page and keep them in config, not in code.
export type Price = {
inputPerMTok: number; // USD per 1M input tokens
outputPerMTok: number; // USD per 1M output tokens (reasoning tokens are billed as output too)
};
export type Usage = { inputTokens: number; outputTokens: number };
export function costUSD(usage: Usage, price: Price): number {
return (usage.inputTokens * price.inputPerMTok + usage.outputTokens * price.outputPerMTok) / 1_000_000;
}
// Running total for a batch of requests, e.g. one night of ticket triage.
export function totalUsage(items: Usage[]): Usage {
return items.reduce(
(sum, u) => ({ inputTokens: sum.inputTokens + u.inputTokens, outputTokens: sum.outputTokens + u.outputTokens }),
{ inputTokens: 0, outputTokens: 0 },
);
}Проверить код можно и без реальных ключей и без трат. Оба SDK читают адрес API из переменных ANTHROPIC_BASE_URL и OPENAI_BASE_URL, поэтому мы направили их на локальный мок-сервер, который отвечает в формате документации: обычным JSON, потоком SSE и ошибками 401, 429, 529. На нём прошли все скрипты урока и 14 тестов node:test: повторы после 429 и 529, отказ без повторов на 401, обрезанные ответы, ошибка посреди потока.
$ node --env-file=.env.mock src/compare.ts
[anthropic] claude-opus-5-5: MOCK CLAUDE: Why should an API key never be stored in frontend code?
tokens in/out: 24/13
[openai] gpt-6-astra: MOCK GPT: Why should an API key never be stored in frontend code?
tokens in/out: 24/13npx tsc -p .проходит без ошибок- При пустой переменной ключа скрипт падает с понятным сообщением и не отправляет запрос
- Ответ Claude собирается только из блоков
text, ответ OpenAI — изoutput_text - Обрезанный ответ помечается:
stop_reason: "max_tokens"илиstatus: "incomplete" - 429 с
retry-afterповторяется, 401 и 400 — нет
Как проверено3 · пройдено3 · частичносверено 12 октября 2026 г.
- запущено, работает
Подготовка проекта (bash), tsconfig.json, .env.example
выполнено во временном проекте scratchpad/kb/verify/llm-api-pervyj-zapros: npm init, type=module, установка @anthropic-ai/sdk 0.133.0, openai 7.32.0, typescript 7.0.2, @types/node 24.19.2; tsc -p tsconfig.article.json (skipLibCheck true) и tsc -p tsconfig.json (skipLibCheck false, src+mock+test) — 0 ошибок
- запущено, работает
src/cost.ts
node:test: costUSD и totalUsage на фиксированных числах (тестовые цены, не прайс)
- запущено, работает
Вывод compare.ts (shellsession)
реальный вывод запуска node --env-file=.env.mock src/compare.ts против мок-сервера; в подписи указано, что с настоящими ключами текст и числа будут другими
- частично
src/anthropic-hello.ts, src/openai-hello.ts, src/history.ts, src/stream.ts, src/compare.ts
tsc --noEmit против типов SDK + запуск Node 24.18 (type stripping) против локального мок-сервера (ANTHROPIC_BASE_URL / OPENAI_BASE_URL), который отвечает JSON и SSE в формате документации, в т.ч. блок thinking перед text; node:test проверяет вывод и тело запросов (model, max_tokens, system, instructions, reasoning, stream, история из 3 сообщений), отказ без ключа без отправки запроса. Реальный вызов API — NOT_RUN (ключей нет по условиям задания)
- частично
src/llm.ts (общий интерфейс, maxRetries/timeout, разбор ошибок)
node:test против мока: 429+529 → SDK повторяет и получает ответ (3 запроса); 429×4 → LLMError retryable, 4 запроса (1+maxRetries 3); 429 с кодом credit_balance_exhausted и 429 Anthropic без retry-after → retryable false; 401 → 1 запрос, retryable false; 400 → requestID в сообщении; stop_reason max_tokens и status incomplete → complete false; refusal → LLMError. Поведение реального API на эти ситуации не проверялось
- частично
src/ask-stream.ts (решение упражнения)
node:test против мока: куски складываются в полный ответ у обоих провайдеров; SSE-событие error (overloaded_error) после 200 отклоняет finalMessage()
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.