How to buildOctober 12, 20267 min read
AI integrationsStep 2 of 8

LLM APIs: a first request to Claude and OpenAI

In short

To call a language model from code, install the official SDK (@anthropic-ai/sdk or openai), keep the key in a server-side environment variable and send a request: at Anthropic that is client.messages.create with model, max_tokens, system and messages; at OpenAI it is client.responses.create with model, instructions and input. Interfaces use streaming, and the SDKs retry 429 and 5xx errors on their own by default; your job is to set limits and a timeout and handle the remaining errors by type.

TutorialLevel: BeginnerLLM APIsPractice: ~1.5–3 h

After this you will be able to

  • send a request to the Anthropic Messages API and the OpenAI Responses API through the official SDKs, with the key taken from an environment variable
  • pass a system prompt and conversation history and extract the answer text without relying on block position
  • enable streaming with both providers and handle an error that arrives mid-stream
  • configure maxRetries and timeout, tell a transient 429 from an exhausted quota and compute usage from usage

Verified with

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)

Checked against docs: October 12, 2026

Contents
  1. 01What you need and where the key lives
  2. 02First request to Claude: the Messages API
  3. 03First request to OpenAI: the Responses API
  4. 04Message roles and the system prompt
  5. 05Streaming the response
  6. 06Errors, limits and retries
  7. 07A common interface, token accounting and verification

What you need and where the key lives

This lesson is for a developer who already writes TypeScript and uses async/await. You need Node.js 24, accounts in the Anthropic Console and on the OpenAI platform, and one API key for each. A key is the password to your bill: anyone who has it spends your money. So there is one rule: the key lives only on the server, in an environment variable, and never ends up in a browser, a mobile app or a repository.

bash
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
Project: ES modules, TypeScript only for type checking — Node.js 24 runs .ts files directly
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}
Strict type checking without a build: tsc checks the code against the types of both SDKs
.env.example
# 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
The template is committed, the .env copy with real keys is not: add .env to .gitignore

First request to Claude: the Messages API

At Anthropic everything goes through one endpoint, the Messages API. A minimal request has a model ID, an output limit max_tokens and a messages array; the system prompt goes into the separate system field. Take the model ID from the models page: as of 2026-10-12 the documentation suggests starting with claude-opus-5-5, and for high-volume simple tasks there is the faster and cheaper claude-haiku-5-5.

src/anthropic-hello.ts
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);
First request: system prompt, extracting text by block type, token usage
bash
npx tsc -p .
node --env-file=.env src/anthropic-hello.ts
node --env-file=.env src/openai-hello.ts
Type check and run: the --env-file flag loads variables from .env without extra packages
  • content is an array of blocks, not a string. Current Claude models always reason, so a thinking block may come before the text; never read content[0].text — filter by type === "text"
  • max_tokens is a hard ceiling for the answer, and the model's reasoning counts toward it. If you get stop_reason: "max_tokens", the answer was cut off
  • stop_reason: "end_turn" means the model finished on its own; "refusal" means it declined the request, and such an answer must not be shown as a normal one
  • usage.input_tokens and usage.output_tokens are the actual consumption you compute cost from

First request to OpenAI: the Responses API

OpenAI has two text APIs: the older Chat Completions and Responses. OpenAI's documentation recommends the Responses API for new applications, especially with reasoning models. The request is simpler: input can be a string, the system prompt goes into instructions, and the SDK assembles the final text in response.output_text. As of 2026-10-12 OpenAI's documentation recommends gpt-6-astra for complex work, gpt-6.1-sol as a balance of cost and quality, and gpt-6-luna for high-volume tasks.

src/openai-hello.ts
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);
The same question through the Responses API; reasoning effort lowered for a simple answer

Message roles and the system prompt

An LLM API does not keep the conversation: every request carries all the history the model should consider. History is a list of messages with the roles user and assistant (the model's earlier answers). The system prompt sets the role, rules and format for the whole conversation and outranks individual messages. At Anthropic it is the system field, at OpenAI it is instructions (or a message with the developer role in input).

src/history.ts
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);
The same conversation for both providers: the whole history in every request

System prompt

Anthropic Messages API
system
OpenAI Responses API
instructions or the developer role

Conversation history

Anthropic Messages API
messages: user / assistant
OpenAI Responses API
input: a string or an array of messages

Output limit

Anthropic Messages API
max_tokens (required)
OpenAI Responses API
max_output_tokens (optional)

Answer text

Anthropic Messages API
content blocks with type: "text"
OpenAI Responses API
output_text

Why it stopped

Anthropic Messages API
stop_reason
OpenAI Responses API
status and incomplete_details

Usage

Anthropic Messages API
usage.input_tokens / output_tokens
OpenAI Responses API
usage.input_tokens / output_tokens

Streaming the response

Without streaming the user waits until the model has generated the whole answer. With streaming (server-sent events, SSE — a stream of events over one HTTP response) the text appears as it is generated. In the Anthropic SDK the most convenient option is the messages.stream() helper: the text event delivers text chunks and finalMessage() returns the assembled message with stop_reason and usage. In the OpenAI SDK a request with stream: true returns an async stream of typed events: text arrives in response.output_text.delta, the end in response.completed.

src/stream.ts
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);
  }
}
Streaming with both providers: print chunks as they arrive, then the final usage

Errors, limits and retries

Both SDKs throw typed errors — subclasses of APIError with a status field: AuthenticationError (401), RateLimitError (429), InternalServerError (5xx), APIConnectionError and APIConnectionTimeoutError for the network and timeouts. Transient failures — network errors, 408, 409, 429 and 5xx — are retried by the SDK twice by default with exponential backoff, honoring the retry-after header. maxRetries sets the number of retries and timeout the timeout in milliseconds (10 minutes by default).

400

What happened
Invalid request: a parameter, format or unsupported value
What to do
Do not retry, fix the request; log the requestID

401 / 403

What happened
Wrong or revoked key, or no permission
What to do
Do not retry, check the key and access

429 with retry-after

What happened
Requests or tokens per minute exceeded
What to do
Wait the given time, slow down or queue the work

429 without retry-after or with a quota code

What happened
Budget, credit or spend limit exhausted
What to do
Retrying will not help: top up or raise the limit

500, 503, 529

What happened
Failure or overload on the provider's side
What to do
Retry with a pause; the SDK does it for you

Network or timeout

What happened
No response in the allotted time
What to do
Retry; use streaming for long answers
src/llm.ts
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);
  }
}
Lesson practice: a common interface to both providers with limits, retries and error handling
  • Retries with backoff are already built into the SDKs: your own retry loop on top multiplies the number of requests, just like retries at several layers of a system
  • retryable on LLMError tells the caller whether the job can be queued for later or the error must be reported right away
  • Anthropic counts limits in requests and input and output tokens per minute, OpenAI in requests and tokens per minute and per day; current values are in the console and in response headers (anthropic-ratelimit-*, x-ratelimit-*)
  • To work with support, log the request ID: requestID on errors, _request_id on Anthropic responses

A common interface, token accounting and verification

The script below calls both providers through one ask function and prints the answer and usage. Cost is computed from usage and the prices on the pricing pages; prices change, so keep them in configuration rather than in code. Remember that model reasoning is billed as output tokens, and conversation history is billed again as input on every request.

src/compare.ts
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;
  }
}
One question, two providers; a failure of one does not stop the other
src/cost.ts
// 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 },
  );
}
Cost of a request and a batch of requests from actual usage

You can verify the code without real keys and without spending anything. Both SDKs read the API address from ANTHROPIC_BASE_URL and OPENAI_BASE_URL, so we pointed them at a local mock server that answers in the documented formats: plain JSON, an SSE stream and 401, 429 and 529 errors. All the lesson scripts and 14 node:test tests passed against it: retries after 429 and 529, no retries on 401, cut-off answers, an error in the middle of a stream.

terminal
$ 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/13
Output against the local mock server: with real keys the answer text and token counts will differ
  • npx tsc -p . passes without errors
  • With an empty key variable the script fails with a clear message and sends no request
  • The Claude answer is built only from text blocks, the OpenAI answer from output_text
  • A cut-off answer is flagged: stop_reason: "max_tokens" or status: "incomplete"
  • A 429 with retry-after is retried, 401 and 400 are not
How this was verified3 · passed3 · partialchecked 12 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.